diff --git a/src/modules/auth/__tests__/permissions.service.spec.ts b/src/modules/auth/__tests__/permissions.service.spec.ts new file mode 100644 index 0000000..9b4e7d7 --- /dev/null +++ b/src/modules/auth/__tests__/permissions.service.spec.ts @@ -0,0 +1,746 @@ +/** + * @fileoverview Unit tests for PermissionsService + * Tests cover CRUD operations, bulk creation, grouping, validation, and role relationships + */ + +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { Permission, PermissionAction } from '../entities/permission.entity.js'; +import { Role } from '../entities/role.entity.js'; +import { + PermissionsService, + PermissionSearchParams, + CreatePermissionDto, + UpdatePermissionDto, + BulkCreatePermissionsDto, +} from '../services/permissions.service.js'; + +describe('PermissionsService', () => { + let permissionsService: PermissionsService; + let mockPermissionRepository: Partial>; + let mockRoleRepository: Partial>; + let mockQueryBuilder: Partial>; + + const mockPermissionId = '550e8400-e29b-41d4-a716-446655440020'; + const mockPermissionId2 = '550e8400-e29b-41d4-a716-446655440021'; + const mockRoleId = '550e8400-e29b-41d4-a716-446655440010'; + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + + const mockPermission: Partial = { + id: mockPermissionId, + resource: 'users', + action: PermissionAction.READ, + description: 'Read users', + module: 'auth', + createdAt: new Date('2026-01-01'), + roles: [], + }; + + const mockPermission2: Partial = { + id: mockPermissionId2, + resource: 'users', + action: PermissionAction.CREATE, + description: 'Create users', + module: 'auth', + createdAt: new Date('2026-01-01'), + roles: [], + }; + + const mockRole: Partial = { + id: mockRoleId, + tenantId: mockTenantId, + name: 'Administrator', + code: 'ADMIN', + permissions: [mockPermission as Permission], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock query builder + mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([{ resource: 'users' }, { resource: 'roles' }]), + }; + + // Setup mock repositories + mockPermissionRepository = { + create: jest.fn().mockReturnValue(mockPermission), + save: jest.fn().mockResolvedValue(mockPermission), + findOne: jest.fn().mockResolvedValue(mockPermission), + find: jest.fn().mockResolvedValue([mockPermission, mockPermission2]), + findAndCount: jest.fn().mockResolvedValue([[mockPermission], 1]), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + mockRoleRepository = { + findOne: jest.fn().mockResolvedValue(mockRole), + }; + + permissionsService = new PermissionsService( + mockPermissionRepository as Repository, + mockRoleRepository as Repository + ); + }); + + describe('findAll', () => { + it('should return all permissions with pagination', async () => { + const params: PermissionSearchParams = { + limit: 100, + offset: 0, + }; + + const result = await permissionsService.findAll(params); + + expect(mockPermissionRepository.findAndCount).toHaveBeenCalledWith({ + where: [{}], + take: 100, + skip: 0, + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + expect(result.data).toEqual([mockPermission]); + expect(result.total).toBe(1); + }); + + it('should filter by resource', async () => { + const params: PermissionSearchParams = { + resource: 'users', + limit: 100, + offset: 0, + }; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].resource).toBe('users'); + }); + + it('should filter by action', async () => { + const params: PermissionSearchParams = { + action: PermissionAction.READ, + limit: 100, + offset: 0, + }; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].action).toBe(PermissionAction.READ); + }); + + it('should filter by module', async () => { + const params: PermissionSearchParams = { + module: 'auth', + limit: 100, + offset: 0, + }; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].module).toBe('auth'); + }); + + it('should filter by search term', async () => { + const params: PermissionSearchParams = { + search: 'user', + limit: 100, + offset: 0, + }; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where).toHaveLength(3); // resource, description, module search + }); + + it('should use default pagination values', async () => { + const params: PermissionSearchParams = {}; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.take).toBe(100); + expect(callArgs.skip).toBe(0); + }); + + it('should combine multiple filters', async () => { + const params: PermissionSearchParams = { + resource: 'users', + action: PermissionAction.READ, + module: 'auth', + limit: 50, + offset: 10, + }; + + await permissionsService.findAll(params); + + const callArgs = (mockPermissionRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].resource).toBe('users'); + expect(callArgs.where[0].action).toBe(PermissionAction.READ); + expect(callArgs.where[0].module).toBe('auth'); + expect(callArgs.take).toBe(50); + expect(callArgs.skip).toBe(10); + }); + }); + + describe('findOne', () => { + it('should return permission by ID with relations', async () => { + const result = await permissionsService.findOne(mockPermissionId); + + expect(mockPermissionRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockPermissionId }, + relations: ['roles'], + }); + expect(result).toEqual(mockPermission); + }); + + it('should return null when permission not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.findOne('non-existent-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByResourceAction', () => { + it('should return permission by resource and action', async () => { + const result = await permissionsService.findByResourceAction('users', PermissionAction.READ); + + expect(mockPermissionRepository.findOne).toHaveBeenCalledWith({ + where: { resource: 'users', action: PermissionAction.READ }, + }); + expect(result).toEqual(mockPermission); + }); + + it('should return null when not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.findByResourceAction( + 'nonexistent', + PermissionAction.DELETE + ); + + expect(result).toBeNull(); + }); + }); + + describe('findByModule', () => { + it('should return permissions for a module', async () => { + const result = await permissionsService.findByModule('auth'); + + expect(mockPermissionRepository.find).toHaveBeenCalledWith({ + where: { module: 'auth' }, + order: { resource: 'ASC', action: 'ASC' }, + }); + expect(result).toHaveLength(2); + }); + }); + + describe('findByResource', () => { + it('should return permissions for a resource', async () => { + const result = await permissionsService.findByResource('users'); + + expect(mockPermissionRepository.find).toHaveBeenCalledWith({ + where: { resource: 'users' }, + order: { action: 'ASC' }, + }); + expect(result).toHaveLength(2); + }); + }); + + describe('create', () => { + it('should create a new permission', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); // No existing permission + + const createDto: CreatePermissionDto = { + resource: 'products', + action: PermissionAction.CREATE, + description: 'Create products', + module: 'inventory', + }; + + const result = await permissionsService.create(createDto); + + expect(mockPermissionRepository.create).toHaveBeenCalledWith({ + resource: createDto.resource, + action: createDto.action, + description: createDto.description, + module: createDto.module, + }); + expect(mockPermissionRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when permission already exists', async () => { + // Permission already exists + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(mockPermission); + + const createDto: CreatePermissionDto = { + resource: 'users', + action: PermissionAction.READ, + }; + + await expect(permissionsService.create(createDto)).rejects.toThrow( + 'Ya existe el permiso read para el recurso users' + ); + }); + + it('should handle null description and module', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const createDto: CreatePermissionDto = { + resource: 'reports', + action: PermissionAction.EXPORT, + }; + + await permissionsService.create(createDto); + + expect(mockPermissionRepository.create).toHaveBeenCalledWith({ + resource: 'reports', + action: PermissionAction.EXPORT, + description: null, + module: null, + }); + }); + }); + + describe('update', () => { + it('should update permission description', async () => { + const updateDto: UpdatePermissionDto = { + description: 'Updated description', + }; + + const result = await permissionsService.update(mockPermissionId, updateDto); + + expect(mockPermissionRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should update permission module', async () => { + const updateDto: UpdatePermissionDto = { + module: 'new-module', + }; + + const result = await permissionsService.update(mockPermissionId, updateDto); + + expect(mockPermissionRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when permission not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.update('non-existent-id', { + description: 'Test', + }); + + expect(result).toBeNull(); + }); + + it('should handle setting values to null', async () => { + const updateDto: UpdatePermissionDto = { + description: undefined, + module: undefined, + }; + + const result = await permissionsService.update(mockPermissionId, updateDto); + + expect(mockPermissionRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('bulkCreateForResource', () => { + it('should create multiple permissions for a resource', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); // None exist + + const dto: BulkCreatePermissionsDto = { + resource: 'products', + actions: [PermissionAction.CREATE, PermissionAction.READ, PermissionAction.UPDATE], + module: 'inventory', + }; + + const result = await permissionsService.bulkCreateForResource(dto); + + expect(mockPermissionRepository.create).toHaveBeenCalledTimes(3); + expect(mockPermissionRepository.save).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(3); + }); + + it('should skip existing permissions', async () => { + // First action exists, second and third do not + mockPermissionRepository.findOne = jest + .fn() + .mockResolvedValueOnce(mockPermission) // Exists + .mockResolvedValueOnce(null) // Does not exist + .mockResolvedValueOnce(null); // Does not exist + + const dto: BulkCreatePermissionsDto = { + resource: 'users', + actions: [PermissionAction.READ, PermissionAction.UPDATE, PermissionAction.DELETE], + module: 'auth', + }; + + const result = await permissionsService.bulkCreateForResource(dto); + + expect(mockPermissionRepository.create).toHaveBeenCalledTimes(2); + expect(mockPermissionRepository.save).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('should return empty array when all permissions exist', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(mockPermission); + + const dto: BulkCreatePermissionsDto = { + resource: 'users', + actions: [PermissionAction.READ, PermissionAction.CREATE], + }; + + const result = await permissionsService.bulkCreateForResource(dto); + + expect(mockPermissionRepository.create).not.toHaveBeenCalled(); + expect(result).toHaveLength(0); + }); + + it('should auto-generate description for bulk created permissions', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const dto: BulkCreatePermissionsDto = { + resource: 'orders', + actions: [PermissionAction.CREATE], + module: 'sales', + }; + + await permissionsService.bulkCreateForResource(dto); + + expect(mockPermissionRepository.create).toHaveBeenCalledWith({ + resource: 'orders', + action: PermissionAction.CREATE, + description: 'create orders', + module: 'sales', + }); + }); + }); + + describe('createCrudPermissions', () => { + it('should create CRUD permissions for a resource', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.createCrudPermissions('customers', 'crm'); + + expect(mockPermissionRepository.create).toHaveBeenCalledTimes(4); // CREATE, READ, UPDATE, DELETE + expect(result).toHaveLength(4); + }); + + it('should work without module parameter', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + await permissionsService.createCrudPermissions('settings'); + + expect(mockPermissionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + module: null, + }) + ); + }); + }); + + describe('getResources', () => { + it('should return list of unique resources', async () => { + const result = await permissionsService.getResources(); + + expect(mockPermissionRepository.createQueryBuilder).toHaveBeenCalledWith('p'); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT p.resource', 'resource'); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('p.resource', 'ASC'); + expect(result).toEqual(['users', 'roles']); + }); + }); + + describe('getModules', () => { + it('should return list of unique modules', async () => { + mockQueryBuilder.getRawMany = jest + .fn() + .mockResolvedValue([{ module: 'auth' }, { module: 'inventory' }]); + + const result = await permissionsService.getModules(); + + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT p.module', 'module'); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('p.module IS NOT NULL'); + expect(result).toEqual(['auth', 'inventory']); + }); + }); + + describe('getPermissionsByModule', () => { + it('should return permissions grouped by module', async () => { + const permissions = [ + { ...mockPermission, module: 'auth' }, + { ...mockPermission2, module: 'auth' }, + { id: '3', resource: 'products', action: PermissionAction.READ, module: 'inventory' }, + ]; + mockPermissionRepository.find = jest.fn().mockResolvedValue(permissions); + + const result = await permissionsService.getPermissionsByModule(); + + expect(mockPermissionRepository.find).toHaveBeenCalledWith({ + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + expect(result).toBeInstanceOf(Map); + expect(result.get('auth')).toHaveLength(2); + expect(result.get('inventory')).toHaveLength(1); + }); + + it('should group null modules under "other"', async () => { + const permissions = [{ ...mockPermission, module: null }]; + mockPermissionRepository.find = jest.fn().mockResolvedValue(permissions); + + const result = await permissionsService.getPermissionsByModule(); + + expect(result.get('other')).toHaveLength(1); + }); + }); + + describe('getPermissionsByResource', () => { + it('should return permissions grouped by resource', async () => { + const permissions = [ + { ...mockPermission, resource: 'users' }, + { ...mockPermission2, resource: 'users' }, + { id: '3', resource: 'roles', action: PermissionAction.READ }, + ]; + mockPermissionRepository.find = jest.fn().mockResolvedValue(permissions); + + const result = await permissionsService.getPermissionsByResource(); + + expect(mockPermissionRepository.find).toHaveBeenCalledWith({ + order: { resource: 'ASC', action: 'ASC' }, + }); + expect(result).toBeInstanceOf(Map); + expect(result.get('users')).toHaveLength(2); + expect(result.get('roles')).toHaveLength(1); + }); + }); + + describe('roleHasPermission', () => { + it('should return true when role has permission', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(mockPermission); + mockRoleRepository.findOne = jest.fn().mockResolvedValue({ + ...mockRole, + permissions: [mockPermission], + }); + + const result = await permissionsService.roleHasPermission( + mockRoleId, + 'users', + PermissionAction.READ + ); + + expect(result).toBe(true); + }); + + it('should return false when role does not have permission', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(mockPermission); + mockRoleRepository.findOne = jest.fn().mockResolvedValue({ + ...mockRole, + permissions: [], + }); + + const result = await permissionsService.roleHasPermission( + mockRoleId, + 'users', + PermissionAction.READ + ); + + expect(result).toBe(false); + }); + + it('should return false when permission does not exist', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.roleHasPermission( + mockRoleId, + 'nonexistent', + PermissionAction.READ + ); + + expect(result).toBe(false); + }); + + it('should return false when role does not exist', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(mockPermission); + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.roleHasPermission( + 'non-existent-role', + 'users', + PermissionAction.READ + ); + + expect(result).toBe(false); + }); + }); + + describe('getRolesWithPermission', () => { + it('should return roles that have the permission', async () => { + const permissionWithRoles = { + ...mockPermission, + roles: [mockRole], + }; + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(permissionWithRoles); + + const result = await permissionsService.getRolesWithPermission(mockPermissionId); + + expect(result).toEqual([mockRole]); + }); + + it('should return empty array when permission not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.getRolesWithPermission('non-existent'); + + expect(result).toEqual([]); + }); + + it('should return empty array when permission has no roles', async () => { + const permissionWithoutRoles = { + ...mockPermission, + roles: null, + }; + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(permissionWithoutRoles); + + const result = await permissionsService.getRolesWithPermission(mockPermissionId); + + expect(result).toEqual([]); + }); + }); + + describe('delete', () => { + it('should delete permission', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue({ + ...mockPermission, + roles: [], + }); + + const result = await permissionsService.delete(mockPermissionId); + + expect(mockPermissionRepository.delete).toHaveBeenCalledWith(mockPermissionId); + expect(result).toBe(true); + }); + + it('should return false when permission not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await permissionsService.delete('non-existent-id'); + + expect(result).toBe(false); + }); + + it('should throw error when permission is assigned to roles', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue({ + ...mockPermission, + roles: [mockRole], + }); + + await expect(permissionsService.delete(mockPermissionId)).rejects.toThrow( + 'No se puede eliminar un permiso asignado a roles' + ); + }); + }); + + describe('deleteByResource', () => { + it('should delete all permissions for a resource', async () => { + mockPermissionRepository.find = jest.fn().mockResolvedValue([ + { ...mockPermission, roles: [] }, + { ...mockPermission2, roles: [] }, + ]); + mockPermissionRepository.findOne = jest.fn().mockResolvedValue({ + ...mockPermission, + roles: [], + }); + mockPermissionRepository.delete = jest.fn().mockResolvedValue({ affected: 2 }); + + const result = await permissionsService.deleteByResource('users'); + + expect(mockPermissionRepository.delete).toHaveBeenCalledWith({ resource: 'users' }); + expect(result).toBe(2); + }); + + it('should throw error when any permission is assigned to roles', async () => { + mockPermissionRepository.find = jest.fn().mockResolvedValue([ + { ...mockPermission, id: 'id1' }, + { ...mockPermission2, id: 'id2' }, + ]); + // First permission is not assigned, second is + mockPermissionRepository.findOne = jest + .fn() + .mockResolvedValueOnce({ ...mockPermission, roles: [] }) + .mockResolvedValueOnce({ ...mockPermission2, roles: [mockRole] }); + + await expect(permissionsService.deleteByResource('users')).rejects.toThrow( + 'El permiso create de users esta asignado a roles' + ); + }); + }); + + describe('Permission Actions', () => { + it('should support all permission actions', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const allActions = [ + PermissionAction.CREATE, + PermissionAction.READ, + PermissionAction.UPDATE, + PermissionAction.DELETE, + PermissionAction.APPROVE, + PermissionAction.CANCEL, + PermissionAction.EXPORT, + ]; + + for (const action of allActions) { + const createDto: CreatePermissionDto = { + resource: 'test', + action, + }; + + await permissionsService.create(createDto); + + expect(mockPermissionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + action, + }) + ); + } + }); + }); + + describe('Global Permissions (No Tenant)', () => { + it('should not include tenantId in permission operations', async () => { + // Permissions are global (no tenantId), verify operations don't include tenant filtering + await permissionsService.findOne(mockPermissionId); + + expect(mockPermissionRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockPermissionId }, + relations: ['roles'], + }); + // Should NOT include tenantId + const callArgs = (mockPermissionRepository.findOne as jest.Mock).mock.calls[0][0]; + expect(callArgs.where.tenantId).toBeUndefined(); + }); + + it('should create permissions without tenantId', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + const createDto: CreatePermissionDto = { + resource: 'global-resource', + action: PermissionAction.READ, + }; + + await permissionsService.create(createDto); + + const callArgs = (mockPermissionRepository.create as jest.Mock).mock.calls[0][0]; + expect(callArgs.tenantId).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/auth/__tests__/roles.service.spec.ts b/src/modules/auth/__tests__/roles.service.spec.ts new file mode 100644 index 0000000..a74162a --- /dev/null +++ b/src/modules/auth/__tests__/roles.service.spec.ts @@ -0,0 +1,733 @@ +/** + * @fileoverview Unit tests for RolesService + * Tests cover CRUD operations, permission management, validation, and tenant isolation + */ + +import { Repository } from 'typeorm'; +import { Role } from '../entities/role.entity.js'; +import { Permission, PermissionAction } from '../entities/permission.entity.js'; +import { User } from '../entities/user.entity.js'; +import { RolesService, RoleSearchParams, CreateRoleDto, UpdateRoleDto } from '../services/roles.service.js'; + +describe('RolesService', () => { + let rolesService: RolesService; + let mockRoleRepository: Partial>; + let mockPermissionRepository: Partial>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440099'; + const mockRoleId = '550e8400-e29b-41d4-a716-446655440010'; + const mockPermissionId1 = '550e8400-e29b-41d4-a716-446655440020'; + const mockPermissionId2 = '550e8400-e29b-41d4-a716-446655440021'; + + const mockPermission1: Partial = { + id: mockPermissionId1, + resource: 'users', + action: PermissionAction.READ, + description: 'Read users', + module: 'auth', + createdAt: new Date('2026-01-01'), + }; + + const mockPermission2: Partial = { + id: mockPermissionId2, + resource: 'users', + action: PermissionAction.CREATE, + description: 'Create users', + module: 'auth', + createdAt: new Date('2026-01-01'), + }; + + const mockRole: Partial = { + id: mockRoleId, + tenantId: mockTenantId, + name: 'Administrator', + code: 'ADMIN', + description: 'Full system access', + isSystem: false, + color: '#FF5733', + permissions: [mockPermission1 as Permission, mockPermission2 as Permission], + users: [], + createdAt: new Date('2026-01-01'), + createdBy: mockUserId, + updatedAt: null, + updatedBy: null, + deletedAt: null, + deletedBy: null, + }; + + const mockSystemRole: Partial = { + id: '550e8400-e29b-41d4-a716-446655440011', + tenantId: mockTenantId, + name: 'Super Admin', + code: 'SUPER_ADMIN', + description: 'System administrator role', + isSystem: true, + color: '#000000', + permissions: [mockPermission1 as Permission], + users: [], + createdAt: new Date('2026-01-01'), + createdBy: null, + updatedAt: null, + updatedBy: null, + deletedAt: null, + deletedBy: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock repositories + mockRoleRepository = { + create: jest.fn().mockReturnValue(mockRole), + save: jest.fn().mockResolvedValue(mockRole), + findOne: jest.fn().mockResolvedValue(mockRole), + find: jest.fn().mockResolvedValue([mockRole]), + findAndCount: jest.fn().mockResolvedValue([[mockRole], 1]), + softDelete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + + mockPermissionRepository = { + findBy: jest.fn().mockResolvedValue([mockPermission1, mockPermission2]), + findOne: jest.fn().mockResolvedValue(mockPermission1), + }; + + rolesService = new RolesService( + mockRoleRepository as Repository, + mockPermissionRepository as Repository + ); + }); + + describe('findAll', () => { + it('should return all roles for a tenant with pagination', async () => { + const params: RoleSearchParams = { + tenantId: mockTenantId, + limit: 50, + offset: 0, + }; + + const result = await rolesService.findAll(params); + + expect(mockRoleRepository.findAndCount).toHaveBeenCalledWith({ + where: [{ tenantId: mockTenantId }], + relations: ['permissions'], + take: 50, + skip: 0, + order: { isSystem: 'DESC', name: 'ASC' }, + }); + expect(result.data).toEqual([mockRole]); + expect(result.total).toBe(1); + }); + + it('should filter by search term', async () => { + const params: RoleSearchParams = { + tenantId: mockTenantId, + search: 'Admin', + limit: 50, + offset: 0, + }; + + await rolesService.findAll(params); + + expect(mockRoleRepository.findAndCount).toHaveBeenCalled(); + const callArgs = (mockRoleRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where).toHaveLength(3); // name, code, description search + }); + + it('should filter by isSystem flag', async () => { + const params: RoleSearchParams = { + tenantId: mockTenantId, + isSystem: true, + limit: 50, + offset: 0, + }; + + await rolesService.findAll(params); + + const callArgs = (mockRoleRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].isSystem).toBe(true); + }); + + it('should use default pagination values', async () => { + const params: RoleSearchParams = { + tenantId: mockTenantId, + }; + + await rolesService.findAll(params); + + const callArgs = (mockRoleRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.take).toBe(50); + expect(callArgs.skip).toBe(0); + }); + }); + + describe('findOne', () => { + it('should return role by ID with relations', async () => { + const result = await rolesService.findOne(mockRoleId, mockTenantId); + + expect(mockRoleRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockRoleId, tenantId: mockTenantId }, + relations: ['permissions', 'users'], + }); + expect(result).toEqual(mockRole); + }); + + it('should return null when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.findOne('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + + it('should enforce tenant isolation', async () => { + const differentTenantId = '550e8400-e29b-41d4-a716-446655440999'; + + await rolesService.findOne(mockRoleId, differentTenantId); + + expect(mockRoleRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockRoleId, tenantId: differentTenantId }, + relations: ['permissions', 'users'], + }); + }); + }); + + describe('findByCode', () => { + it('should return role by code', async () => { + const result = await rolesService.findByCode('ADMIN', mockTenantId); + + expect(mockRoleRepository.findOne).toHaveBeenCalledWith({ + where: { code: 'ADMIN', tenantId: mockTenantId }, + relations: ['permissions'], + }); + expect(result).toEqual(mockRole); + }); + + it('should return null when code not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.findByCode('NON_EXISTENT', mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create a new role', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); // No existing role with same code + + const createDto: CreateRoleDto = { + name: 'New Role', + code: 'NEW_ROLE', + description: 'A new role', + isSystem: false, + color: '#00FF00', + }; + + const result = await rolesService.create(mockTenantId, createDto, mockUserId); + + expect(mockRoleRepository.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + name: createDto.name, + code: createDto.code, + description: createDto.description, + isSystem: false, + color: createDto.color, + createdBy: mockUserId, + }); + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should create role with permissions', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const createDto: CreateRoleDto = { + name: 'Role With Permissions', + code: 'ROLE_PERMS', + permissionIds: [mockPermissionId1, mockPermissionId2], + }; + + await rolesService.create(mockTenantId, createDto, mockUserId); + + expect(mockPermissionRepository.findBy).toHaveBeenCalledWith({ + id: expect.anything(), + }); + expect(mockRoleRepository.save).toHaveBeenCalled(); + }); + + it('should throw error when code already exists', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(mockRole); + + const createDto: CreateRoleDto = { + name: 'Duplicate Role', + code: 'ADMIN', + }; + + await expect(rolesService.create(mockTenantId, createDto, mockUserId)).rejects.toThrow( + 'Ya existe un rol con el codigo "ADMIN"' + ); + }); + + it('should default isSystem to false', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const createDto: CreateRoleDto = { + name: 'Non System Role', + code: 'NON_SYS', + }; + + await rolesService.create(mockTenantId, createDto, mockUserId); + + expect(mockRoleRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + isSystem: false, + }) + ); + }); + }); + + describe('update', () => { + it('should update role basic fields', async () => { + const updateDto: UpdateRoleDto = { + name: 'Updated Role', + description: 'Updated description', + color: '#0000FF', + }; + + const result = await rolesService.update(mockRoleId, mockTenantId, updateDto, mockUserId); + + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.update( + 'non-existent-id', + mockTenantId, + { name: 'Test' }, + mockUserId + ); + + expect(result).toBeNull(); + }); + + it('should throw error when changing code to existing code', async () => { + const existingRole = { ...mockRole, id: 'other-id' }; + mockRoleRepository.findOne = jest + .fn() + .mockResolvedValueOnce(mockRole) // findOne for update + .mockResolvedValueOnce(existingRole); // findByCode check + + const updateDto: UpdateRoleDto = { + code: 'EXISTING_CODE', + }; + + await expect( + rolesService.update(mockRoleId, mockTenantId, updateDto, mockUserId) + ).rejects.toThrow('Ya existe un rol con el codigo'); + }); + + it('should restrict system role modifications', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(mockSystemRole); + + const updateDto: UpdateRoleDto = { + name: 'Cannot Change Name', + code: 'CANNOT_CHANGE', + }; + + await expect( + rolesService.update(mockSystemRole.id!, mockTenantId, updateDto, mockUserId) + ).rejects.toThrow('Los roles del sistema solo permiten modificar descripcion y color'); + }); + + it('should allow description and color changes on system roles', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(mockSystemRole); + mockRoleRepository.save = jest.fn().mockResolvedValue(mockSystemRole); + + const updateDto: UpdateRoleDto = { + description: 'Updated system description', + color: '#FFFFFF', + }; + + const result = await rolesService.update( + mockSystemRole.id!, + mockTenantId, + updateDto, + mockUserId + ); + + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should update permissions when provided', async () => { + const updateDto: UpdateRoleDto = { + permissionIds: [mockPermissionId1], + }; + + await rolesService.update(mockRoleId, mockTenantId, updateDto, mockUserId); + + expect(mockPermissionRepository.findBy).toHaveBeenCalled(); + expect(mockRoleRepository.save).toHaveBeenCalled(); + }); + }); + + describe('assignPermissions', () => { + it('should assign permissions to role', async () => { + const dto = { permissionIds: [mockPermissionId1, mockPermissionId2] }; + + const result = await rolesService.assignPermissions( + mockRoleId, + mockTenantId, + dto, + mockUserId + ); + + expect(mockPermissionRepository.findBy).toHaveBeenCalled(); + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.assignPermissions( + 'non-existent-id', + mockTenantId, + { permissionIds: [mockPermissionId1] }, + mockUserId + ); + + expect(result).toBeNull(); + }); + + it('should replace all existing permissions', async () => { + const dto = { permissionIds: [mockPermissionId1] }; + + await rolesService.assignPermissions(mockRoleId, mockTenantId, dto, mockUserId); + + expect(mockPermissionRepository.findBy).toHaveBeenCalledWith({ + id: expect.anything(), + }); + }); + }); + + describe('addPermission', () => { + it('should add a single permission to role', async () => { + const newPermissionId = '550e8400-e29b-41d4-a716-446655440030'; + const roleWithoutPermission = { ...mockRole, permissions: [] }; + mockRoleRepository.findOne = jest.fn().mockResolvedValue(roleWithoutPermission); + mockPermissionRepository.findOne = jest.fn().mockResolvedValue({ + id: newPermissionId, + resource: 'settings', + action: PermissionAction.READ, + }); + + const result = await rolesService.addPermission( + mockRoleId, + mockTenantId, + newPermissionId, + mockUserId + ); + + expect(mockPermissionRepository.findOne).toHaveBeenCalledWith({ + where: { id: newPermissionId }, + }); + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should not add duplicate permission', async () => { + // Role already has mockPermission1 + const result = await rolesService.addPermission( + mockRoleId, + mockTenantId, + mockPermissionId1, + mockUserId + ); + + expect(mockRoleRepository.save).not.toHaveBeenCalled(); + expect(result).toEqual(mockRole); + }); + + it('should throw error when permission not found', async () => { + mockPermissionRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + rolesService.addPermission(mockRoleId, mockTenantId, 'non-existent', mockUserId) + ).rejects.toThrow('Permiso no encontrado'); + }); + + it('should return null when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.addPermission( + 'non-existent-id', + mockTenantId, + mockPermissionId1, + mockUserId + ); + + expect(result).toBeNull(); + }); + }); + + describe('removePermission', () => { + it('should remove permission from role', async () => { + const result = await rolesService.removePermission( + mockRoleId, + mockTenantId, + mockPermissionId1, + mockUserId + ); + + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.removePermission( + 'non-existent-id', + mockTenantId, + mockPermissionId1, + mockUserId + ); + + expect(result).toBeNull(); + }); + }); + + describe('getPermissions', () => { + it('should return permissions for role', async () => { + const result = await rolesService.getPermissions(mockRoleId, mockTenantId); + + expect(result).toEqual(mockRole.permissions); + }); + + it('should return empty array when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.getPermissions('non-existent-id', mockTenantId); + + expect(result).toEqual([]); + }); + }); + + describe('getUsers', () => { + it('should return users for role', async () => { + const mockUser: Partial = { + id: mockUserId, + email: 'test@example.com', + }; + const roleWithUsers = { ...mockRole, users: [mockUser] }; + mockRoleRepository.findOne = jest.fn().mockResolvedValue(roleWithUsers); + + const result = await rolesService.getUsers(mockRoleId, mockTenantId); + + expect(result).toEqual([mockUser]); + }); + + it('should return empty array when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.getUsers('non-existent-id', mockTenantId); + + expect(result).toEqual([]); + }); + }); + + describe('getUserCount', () => { + it('should return user count for role', async () => { + const mockUsers = [{ id: 'user1' }, { id: 'user2' }]; + const roleWithUsers = { ...mockRole, users: mockUsers }; + mockRoleRepository.findOne = jest.fn().mockResolvedValue(roleWithUsers); + + const result = await rolesService.getUserCount(mockRoleId, mockTenantId); + + expect(result).toBe(2); + }); + + it('should return 0 when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.getUserCount('non-existent-id', mockTenantId); + + expect(result).toBe(0); + }); + }); + + describe('getSystemRoles', () => { + it('should return only system roles', async () => { + mockRoleRepository.find = jest.fn().mockResolvedValue([mockSystemRole]); + + const result = await rolesService.getSystemRoles(mockTenantId); + + expect(mockRoleRepository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, isSystem: true }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + expect(result).toEqual([mockSystemRole]); + }); + }); + + describe('getCustomRoles', () => { + it('should return only custom (non-system) roles', async () => { + mockRoleRepository.find = jest.fn().mockResolvedValue([mockRole]); + + const result = await rolesService.getCustomRoles(mockTenantId); + + expect(mockRoleRepository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, isSystem: false }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + expect(result).toEqual([mockRole]); + }); + }); + + describe('cloneRole', () => { + it('should clone role with new code and name', async () => { + mockRoleRepository.findOne = jest + .fn() + .mockResolvedValueOnce(mockRole) // Source role + .mockResolvedValueOnce(null); // No existing role with new code + + const result = await rolesService.cloneRole( + mockRoleId, + mockTenantId, + 'CLONED_ROLE', + 'Cloned Role', + mockUserId + ); + + expect(mockRoleRepository.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + name: 'Cloned Role', + code: 'CLONED_ROLE', + description: mockRole.description, + isSystem: false, // Cloned roles are never system roles + color: mockRole.color, + permissions: mockRole.permissions, + createdBy: mockUserId, + }); + expect(mockRoleRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when source role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.cloneRole( + 'non-existent-id', + mockTenantId, + 'NEW_CODE', + 'New Name', + mockUserId + ); + + expect(result).toBeNull(); + }); + + it('should throw error when new code already exists', async () => { + mockRoleRepository.findOne = jest + .fn() + .mockResolvedValueOnce(mockRole) // Source role + .mockResolvedValueOnce(mockRole); // Existing role with same code + + await expect( + rolesService.cloneRole(mockRoleId, mockTenantId, 'ADMIN', 'Cloned Admin', mockUserId) + ).rejects.toThrow('Ya existe un rol con el codigo "ADMIN"'); + }); + }); + + describe('delete', () => { + it('should soft delete a role', async () => { + const result = await rolesService.delete(mockRoleId, mockTenantId); + + expect(mockRoleRepository.softDelete).toHaveBeenCalledWith(mockRoleId); + expect(result).toBe(true); + }); + + it('should return false when role not found', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await rolesService.delete('non-existent-id', mockTenantId); + + expect(result).toBe(false); + }); + + it('should throw error when deleting system role', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(mockSystemRole); + + await expect(rolesService.delete(mockSystemRole.id!, mockTenantId)).rejects.toThrow( + 'No se pueden eliminar roles del sistema' + ); + }); + + it('should throw error when role has assigned users', async () => { + const roleWithUsers = { + ...mockRole, + users: [{ id: 'user1' }], + }; + mockRoleRepository.findOne = jest.fn().mockResolvedValue(roleWithUsers); + + await expect(rolesService.delete(mockRoleId, mockTenantId)).rejects.toThrow( + 'No se puede eliminar un rol que tiene usuarios asignados' + ); + }); + }); + + describe('Tenant Isolation', () => { + it('should filter by tenantId in findAll', async () => { + const params: RoleSearchParams = { + tenantId: mockTenantId, + }; + + await rolesService.findAll(params); + + const callArgs = (mockRoleRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].tenantId).toBe(mockTenantId); + }); + + it('should include tenantId in create', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + + const createDto: CreateRoleDto = { + name: 'Tenant Role', + code: 'TENANT_ROLE', + }; + + await rolesService.create(mockTenantId, createDto, mockUserId); + + expect(mockRoleRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: mockTenantId, + }) + ); + }); + + it('should enforce tenantId in findOne', async () => { + await rolesService.findOne(mockRoleId, mockTenantId); + + expect(mockRoleRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockRoleId, tenantId: mockTenantId }, + relations: ['permissions', 'users'], + }); + }); + + it('should not access roles from different tenant', async () => { + mockRoleRepository.findOne = jest.fn().mockResolvedValue(null); + const differentTenantId = '550e8400-e29b-41d4-a716-446655440002'; + + const result = await rolesService.findOne(mockRoleId, differentTenantId); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/modules/carta-porte/__tests__/carta-porte.service.spec.ts b/src/modules/carta-porte/__tests__/carta-porte.service.spec.ts new file mode 100644 index 0000000..12a348e --- /dev/null +++ b/src/modules/carta-porte/__tests__/carta-porte.service.spec.ts @@ -0,0 +1,1013 @@ +/** + * @fileoverview Unit tests for CartaPorteService + * Tests cover CRUD operations, validation, timbrado, cancelacion, and error handling + * Following pattern from financial/__tests__/accounts.service.spec.ts + */ + +import { Repository } from 'typeorm'; +import { + CartaPorte, + EstadoCartaPorte, + TipoCfdiCartaPorte, + UbicacionCartaPorte, + MercanciaCartaPorte, + FiguraTransporte, + AutotransporteCartaPorte, +} from '../entities'; +import { + CartaPorteService, + CartaPorteSearchParams, + CreateCartaPorteDto, + UpdateCartaPorteDto, + TimbrarCartaPorteDto, + CancelarCartaPorteDto, +} from '../services/carta-porte.service'; + +describe('CartaPorteService', () => { + let service: CartaPorteService; + let mockCartaPorteRepository: Partial>; + let mockUbicacionRepository: Partial>; + let mockMercanciaRepository: Partial>; + let mockFiguraRepository: Partial>; + let mockAutotransporteRepository: Partial>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockViajeId = '550e8400-e29b-41d4-a716-446655440003'; + const mockCartaPorteId = '550e8400-e29b-41d4-a716-446655440010'; + + // Mock Ubicacion + const mockUbicacion: Partial = { + id: '550e8400-e29b-41d4-a716-446655440020', + tenantId: mockTenantId, + cartaPorteId: mockCartaPorteId, + tipoUbicacion: 'Origen', + codigoPostal: '06600', + secuencia: 1, + }; + + const mockUbicacionDestino: Partial = { + id: '550e8400-e29b-41d4-a716-446655440021', + tenantId: mockTenantId, + cartaPorteId: mockCartaPorteId, + tipoUbicacion: 'Destino', + codigoPostal: '44100', + secuencia: 2, + }; + + // Mock Mercancia + const mockMercancia: Partial = { + id: '550e8400-e29b-41d4-a716-446655440030', + tenantId: mockTenantId, + cartaPorteId: mockCartaPorteId, + bienesTransp: '31161700', + descripcion: 'Materiales de construccion', + cantidad: 100, + claveUnidad: 'KGM', + pesoEnKg: 1000, + secuencia: 1, + }; + + // Mock Figura Transporte + const mockFigura: Partial = { + id: '550e8400-e29b-41d4-a716-446655440040', + tenantId: mockTenantId, + cartaPorteId: mockCartaPorteId, + tipoFigura: '01', + rfcFigura: 'XAXX010101000', + nombreFigura: 'Juan Perez', + numLicencia: 'ABC123456', + }; + + // Mock Autotransporte + const mockAutotransporte: Partial = { + id: '550e8400-e29b-41d4-a716-446655440050', + tenantId: mockTenantId, + cartaPorteId: mockCartaPorteId, + permSct: 'TPAF01', + numPermisoSct: 'PERM123456', + configVehicular: 'C2', + placaVm: 'ABC1234', + anioModeloVm: 2022, + }; + + // Mock CartaPorte completa + const mockCartaPorte: Partial = { + id: mockCartaPorteId, + tenantId: mockTenantId, + viajeId: mockViajeId, + tipoCfdi: TipoCfdiCartaPorte.INGRESO, + versionCartaPorte: '3.1', + serie: 'CP', + folio: '001', + emisorRfc: 'XAXX010101000', + emisorNombre: 'Transportes SA de CV', + emisorRegimenFiscal: '601', + receptorRfc: 'XBXX010101000', + receptorNombre: 'Cliente SA de CV', + receptorUsoCfdi: 'G03', + receptorDomicilioFiscalCp: '06600', + subtotal: 10000, + total: 11600, + moneda: 'MXN', + transporteInternacional: false, + permisoSct: 'TPAF01', + numPermisoSct: 'PERM123456', + configVehicular: 'C2', + pesoBrutoTotal: 1000, + unidadPeso: 'KGM', + numTotalMercancias: 1, + estado: EstadoCartaPorte.BORRADOR, + ubicaciones: [mockUbicacion as UbicacionCartaPorte, mockUbicacionDestino as UbicacionCartaPorte], + mercancias: [mockMercancia as MercanciaCartaPorte], + figuras: [mockFigura as FiguraTransporte], + autotransporte: [mockAutotransporte as AutotransporteCartaPorte], + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdById: mockUserId, + }; + + // Mock CartaPorte validada + const mockCartaPorteValidada: Partial = { + ...mockCartaPorte, + estado: EstadoCartaPorte.VALIDADA, + }; + + // Mock CartaPorte timbrada + const mockCartaPorteTimbrada: Partial = { + ...mockCartaPorte, + estado: EstadoCartaPorte.TIMBRADA, + uuidCfdi: '550e8400-e29b-41d4-a716-446655440099', + fechaTimbrado: new Date('2026-01-02'), + xmlCfdi: '', + xmlCartaPorte: '', + }; + + // Mock CartaPorte cancelada + const mockCartaPorteCancelada: Partial = { + ...mockCartaPorteTimbrada, + estado: EstadoCartaPorte.CANCELADA, + fechaCancelacion: new Date('2026-01-03'), + motivoCancelacion: 'Error en datos', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock repositories + mockCartaPorteRepository = { + create: jest.fn().mockReturnValue(mockCartaPorte), + save: jest.fn().mockResolvedValue(mockCartaPorte), + findOne: jest.fn().mockResolvedValue(mockCartaPorte), + find: jest.fn().mockResolvedValue([mockCartaPorte]), + findAndCount: jest.fn().mockResolvedValue([[mockCartaPorte], 1]), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + + mockUbicacionRepository = { + create: jest.fn().mockReturnValue(mockUbicacion), + save: jest.fn().mockResolvedValue(mockUbicacion), + findOne: jest.fn().mockResolvedValue(mockUbicacion), + }; + + mockMercanciaRepository = { + create: jest.fn().mockReturnValue(mockMercancia), + save: jest.fn().mockResolvedValue(mockMercancia), + findOne: jest.fn().mockResolvedValue(mockMercancia), + }; + + mockFiguraRepository = { + create: jest.fn().mockReturnValue(mockFigura), + save: jest.fn().mockResolvedValue(mockFigura), + findOne: jest.fn().mockResolvedValue(mockFigura), + }; + + mockAutotransporteRepository = { + create: jest.fn().mockReturnValue(mockAutotransporte), + save: jest.fn().mockResolvedValue(mockAutotransporte), + findOne: jest.fn().mockResolvedValue(mockAutotransporte), + }; + + // Create service instance with mocked repositories + service = new CartaPorteService( + mockCartaPorteRepository as Repository, + mockUbicacionRepository as Repository, + mockMercanciaRepository as Repository, + mockFiguraRepository as Repository, + mockAutotransporteRepository as Repository, + ); + }); + + describe('findAll', () => { + it('should return paginated cartas porte with default params', async () => { + const params: CartaPorteSearchParams = { tenantId: mockTenantId }; + + const result = await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalled(); + expect(result.data).toEqual([mockCartaPorte]); + expect(result.total).toBe(1); + }); + + it('should filter by estado', async () => { + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + estado: EstadoCartaPorte.BORRADOR, + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ estado: EstadoCartaPorte.BORRADOR }), + ]), + }) + ); + }); + + it('should filter by tipoCfdi', async () => { + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + tipoCfdi: TipoCfdiCartaPorte.INGRESO, + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ tipoCfdi: TipoCfdiCartaPorte.INGRESO }), + ]), + }) + ); + }); + + it('should filter by viajeId', async () => { + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + viajeId: mockViajeId, + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ viajeId: mockViajeId }), + ]), + }) + ); + }); + + it('should filter by search term across multiple fields', async () => { + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + search: 'Transportes', + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalled(); + // Verify multiple where clauses are generated for search + const callArgs = (mockCartaPorteRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where.length).toBeGreaterThan(1); + }); + + it('should filter by date range', async () => { + const fechaDesde = new Date('2026-01-01'); + const fechaHasta = new Date('2026-01-31'); + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + fechaDesde, + fechaHasta, + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should apply pagination with limit and offset', async () => { + const params: CartaPorteSearchParams = { + tenantId: mockTenantId, + limit: 10, + offset: 20, + }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + skip: 20, + }) + ); + }); + + it('should include relations in query', async () => { + const params: CartaPorteSearchParams = { tenantId: mockTenantId }; + + await service.findAll(params); + + expect(mockCartaPorteRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }) + ); + }); + }); + + describe('findOne', () => { + it('should return carta porte by id and tenantId', async () => { + const result = await service.findOne(mockCartaPorteId, mockTenantId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockCartaPorteId, tenantId: mockTenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }); + expect(result).toEqual(mockCartaPorte); + }); + + it('should return null when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.findOne('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + + it('should enforce tenant isolation', async () => { + const differentTenantId = '550e8400-e29b-41d4-a716-446655440999'; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.findOne(mockCartaPorteId, differentTenantId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockCartaPorteId, tenantId: differentTenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }); + expect(result).toBeNull(); + }); + }); + + describe('findByViaje', () => { + it('should return cartas porte for a viaje', async () => { + const result = await service.findByViaje(mockViajeId, mockTenantId); + + expect(mockCartaPorteRepository.find).toHaveBeenCalledWith({ + where: { viajeId: mockViajeId, tenantId: mockTenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual([mockCartaPorte]); + }); + }); + + describe('findByUuid', () => { + it('should return carta porte by UUID CFDI', async () => { + const uuidCfdi = '550e8400-e29b-41d4-a716-446655440099'; + + const result = await service.findByUuid(uuidCfdi, mockTenantId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalledWith({ + where: { uuidCfdi, tenantId: mockTenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }); + }); + }); + + describe('create', () => { + it('should create a new carta porte in BORRADOR state', async () => { + const createDto: CreateCartaPorteDto = { + viajeId: mockViajeId, + tipoCfdi: TipoCfdiCartaPorte.INGRESO, + emisorRfc: 'XAXX010101000', + emisorNombre: 'Transportes SA de CV', + receptorRfc: 'XBXX010101000', + receptorNombre: 'Cliente SA de CV', + }; + + const result = await service.create(mockTenantId, createDto, mockUserId); + + expect(mockCartaPorteRepository.create).toHaveBeenCalledWith({ + ...createDto, + tenantId: mockTenantId, + estado: EstadoCartaPorte.BORRADOR, + createdById: mockUserId, + }); + expect(mockCartaPorteRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should set default version to 3.1', async () => { + const createDto: CreateCartaPorteDto = { + viajeId: mockViajeId, + tipoCfdi: TipoCfdiCartaPorte.TRASLADO, + emisorRfc: 'XAXX010101000', + emisorNombre: 'Transportes SA de CV', + receptorRfc: 'XBXX010101000', + receptorNombre: 'Cliente SA de CV', + }; + + await service.create(mockTenantId, createDto, mockUserId); + + expect(mockCartaPorteRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + estado: EstadoCartaPorte.BORRADOR, + }) + ); + }); + }); + + describe('update', () => { + it('should update carta porte in BORRADOR state', async () => { + const updateDto: UpdateCartaPorteDto = { + emisorNombre: 'Updated Name', + total: 15000, + }; + + mockCartaPorteRepository.save = jest.fn().mockResolvedValue({ + ...mockCartaPorte, + ...updateDto, + }); + + const result = await service.update(mockCartaPorteId, mockTenantId, updateDto, mockUserId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalled(); + expect(mockCartaPorteRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const updateDto: UpdateCartaPorteDto = { emisorNombre: 'Updated' }; + const result = await service.update('non-existent-id', mockTenantId, updateDto, mockUserId); + + expect(result).toBeNull(); + }); + + it('should throw error when updating TIMBRADA carta porte', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteTimbrada); + + const updateDto: UpdateCartaPorteDto = { emisorNombre: 'Updated' }; + + await expect( + service.update(mockCartaPorteId, mockTenantId, updateDto, mockUserId) + ).rejects.toThrow('No se puede modificar una Carta Porte ya timbrada'); + }); + + it('should throw error when updating CANCELADA carta porte', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteCancelada); + + const updateDto: UpdateCartaPorteDto = { emisorNombre: 'Updated' }; + + await expect( + service.update(mockCartaPorteId, mockTenantId, updateDto, mockUserId) + ).rejects.toThrow('No se puede modificar una Carta Porte cancelada'); + }); + }); + + describe('validar', () => { + it('should return valid=true when all required fields are present', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return valid=false when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.validar('non-existent-id', mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Carta Porte no encontrada'); + }); + + it('should return errors when emisor RFC is missing', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, emisorRfc: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('RFC del emisor es requerido'); + }); + + it('should return errors when emisor nombre is missing', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, emisorNombre: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Nombre del emisor es requerido'); + }); + + it('should return errors when receptor RFC is missing', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, receptorRfc: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('RFC del receptor es requerido'); + }); + + it('should return errors when less than 2 ubicaciones', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, ubicaciones: [mockUbicacion] }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Se requieren al menos 2 ubicaciones (origen y destino)'); + }); + + it('should return errors when no mercancias', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, mercancias: [] }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Se requiere al menos una mercancia'); + }); + + it('should return errors when no figuras de transporte', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, figuras: [] }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Se requiere al menos una figura de transporte (operador)'); + }); + + it('should return errors when no autotransporte', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, autotransporte: [] }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Se requiere informacion de autotransporte'); + }); + + it('should return errors when permisoSct is missing', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, permisoSct: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Tipo de permiso SCT es requerido'); + }); + + it('should return errors when configVehicular is missing', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, configVehicular: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Configuracion vehicular es requerida'); + }); + + it('should require total for CFDI de INGRESO', async () => { + const ingresoSinTotal = { + ...mockCartaPorte, + tipoCfdi: TipoCfdiCartaPorte.INGRESO, + total: null, + }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(ingresoSinTotal); + + const result = await service.validar(mockCartaPorteId, mockTenantId); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Total del CFDI es requerido para tipo Ingreso'); + }); + }); + + describe('marcarValidada', () => { + it('should mark carta porte as VALIDADA when validation passes', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + mockCartaPorteRepository.save = jest.fn().mockResolvedValue({ + ...mockCartaPorte, + estado: EstadoCartaPorte.VALIDADA, + }); + + const result = await service.marcarValidada(mockCartaPorteId, mockTenantId); + + expect(result).toBeDefined(); + expect(mockCartaPorteRepository.save).toHaveBeenCalled(); + }); + + it('should return null when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.marcarValidada('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + + it('should throw error when validation fails', async () => { + const incompleteCartaPorte = { ...mockCartaPorte, emisorRfc: null }; + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(incompleteCartaPorte); + + await expect( + service.marcarValidada(mockCartaPorteId, mockTenantId) + ).rejects.toThrow('Carta Porte no valida'); + }); + }); + + describe('timbrar', () => { + it('should timbrar carta porte in VALIDADA state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteValidada); + + const timbrarDto: TimbrarCartaPorteDto = { + uuidCfdi: '550e8400-e29b-41d4-a716-446655440099', + fechaTimbrado: new Date('2026-01-02'), + xmlCfdi: '', + xmlCartaPorte: '', + pdfUrl: 'https://example.com/pdf/carta-porte.pdf', + qrUrl: 'https://example.com/qr/carta-porte.png', + }; + + mockCartaPorteRepository.save = jest.fn().mockResolvedValue({ + ...mockCartaPorteValidada, + ...timbrarDto, + estado: EstadoCartaPorte.TIMBRADA, + }); + + const result = await service.timbrar(mockCartaPorteId, mockTenantId, timbrarDto); + + expect(result).toBeDefined(); + expect(mockCartaPorteRepository.save).toHaveBeenCalled(); + }); + + it('should return null when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const timbrarDto: TimbrarCartaPorteDto = { + uuidCfdi: '550e8400-e29b-41d4-a716-446655440099', + fechaTimbrado: new Date(), + xmlCfdi: '', + xmlCartaPorte: '', + }; + + const result = await service.timbrar('non-existent-id', mockTenantId, timbrarDto); + + expect(result).toBeNull(); + }); + + it('should throw error when carta porte is not VALIDADA', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); // BORRADOR + + const timbrarDto: TimbrarCartaPorteDto = { + uuidCfdi: '550e8400-e29b-41d4-a716-446655440099', + fechaTimbrado: new Date(), + xmlCfdi: '', + xmlCartaPorte: '', + }; + + await expect( + service.timbrar(mockCartaPorteId, mockTenantId, timbrarDto) + ).rejects.toThrow('La Carta Porte debe estar validada antes de timbrar'); + }); + }); + + describe('cancelar', () => { + it('should cancel carta porte in TIMBRADA state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteTimbrada); + + const cancelarDto: CancelarCartaPorteDto = { + motivoCancelacion: 'Error en datos del receptor', + uuidSustitucion: '550e8400-e29b-41d4-a716-446655440100', + }; + + mockCartaPorteRepository.save = jest.fn().mockResolvedValue({ + ...mockCartaPorteTimbrada, + estado: EstadoCartaPorte.CANCELADA, + fechaCancelacion: expect.any(Date), + motivoCancelacion: cancelarDto.motivoCancelacion, + uuidSustitucion: cancelarDto.uuidSustitucion, + }); + + const result = await service.cancelar(mockCartaPorteId, mockTenantId, cancelarDto); + + expect(result).toBeDefined(); + expect(mockCartaPorteRepository.save).toHaveBeenCalled(); + }); + + it('should return null when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const cancelarDto: CancelarCartaPorteDto = { + motivoCancelacion: 'Error', + }; + + const result = await service.cancelar('non-existent-id', mockTenantId, cancelarDto); + + expect(result).toBeNull(); + }); + + it('should throw error when carta porte is not TIMBRADA', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); // BORRADOR + + const cancelarDto: CancelarCartaPorteDto = { + motivoCancelacion: 'Error', + }; + + await expect( + service.cancelar(mockCartaPorteId, mockTenantId, cancelarDto) + ).rejects.toThrow('Solo se pueden cancelar Cartas Porte timbradas'); + }); + }); + + describe('addUbicacion', () => { + it('should add ubicacion to carta porte in BORRADOR state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + + const ubicacionData: Partial = { + tipoUbicacion: 'Origen', + codigoPostal: '06600', + secuencia: 1, + }; + + const result = await service.addUbicacion(mockCartaPorteId, mockTenantId, ubicacionData); + + expect(mockUbicacionRepository.create).toHaveBeenCalledWith({ + ...ubicacionData, + cartaPorteId: mockCartaPorteId, + }); + expect(mockUbicacionRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + service.addUbicacion(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Carta Porte no encontrada'); + }); + + it('should throw error when carta porte is not BORRADOR', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteValidada); + + await expect( + service.addUbicacion(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Solo se pueden agregar ubicaciones en estado BORRADOR'); + }); + }); + + describe('addMercancia', () => { + it('should add mercancia to carta porte in BORRADOR state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + + const mercanciaData: Partial = { + bienesTransp: '31161700', + descripcion: 'Test mercancia', + cantidad: 10, + claveUnidad: 'KGM', + pesoEnKg: 100, + secuencia: 2, + }; + + const result = await service.addMercancia(mockCartaPorteId, mockTenantId, mercanciaData); + + expect(mockMercanciaRepository.create).toHaveBeenCalledWith({ + ...mercanciaData, + cartaPorteId: mockCartaPorteId, + }); + expect(mockMercanciaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + service.addMercancia(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Carta Porte no encontrada'); + }); + + it('should throw error when carta porte is not BORRADOR', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteTimbrada); + + await expect( + service.addMercancia(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Solo se pueden agregar mercancias en estado BORRADOR'); + }); + }); + + describe('addFigura', () => { + it('should add figura to carta porte in BORRADOR state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + + const figuraData: Partial = { + tipoFigura: '01', + rfcFigura: 'XAXX010101000', + nombreFigura: 'Operador Test', + numLicencia: 'LIC123456', + }; + + const result = await service.addFigura(mockCartaPorteId, mockTenantId, figuraData); + + expect(mockFiguraRepository.create).toHaveBeenCalledWith({ + ...figuraData, + cartaPorteId: mockCartaPorteId, + }); + expect(mockFiguraRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + service.addFigura(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Carta Porte no encontrada'); + }); + + it('should throw error when carta porte is not BORRADOR', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteCancelada); + + await expect( + service.addFigura(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Solo se pueden agregar figuras en estado BORRADOR'); + }); + }); + + describe('addAutotransporte', () => { + it('should add autotransporte to carta porte in BORRADOR state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + + const autoData: Partial = { + permSct: 'TPAF01', + numPermisoSct: 'PERM999', + configVehicular: 'C3', + placaVm: 'XYZ9999', + anioModeloVm: 2024, + }; + + const result = await service.addAutotransporte(mockCartaPorteId, mockTenantId, autoData); + + expect(mockAutotransporteRepository.create).toHaveBeenCalledWith({ + ...autoData, + cartaPorteId: mockCartaPorteId, + }); + expect(mockAutotransporteRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + service.addAutotransporte(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Carta Porte no encontrada'); + }); + + it('should throw error when carta porte is not BORRADOR', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteValidada); + + await expect( + service.addAutotransporte(mockCartaPorteId, mockTenantId, {}) + ).rejects.toThrow('Solo se puede agregar autotransporte en estado BORRADOR'); + }); + }); + + describe('getPendientesTimbrar', () => { + it('should return cartas porte in BORRADOR or VALIDADA state', async () => { + mockCartaPorteRepository.find = jest.fn().mockResolvedValue([ + mockCartaPorte, + mockCartaPorteValidada, + ]); + + const result = await service.getPendientesTimbrar(mockTenantId); + + expect(mockCartaPorteRepository.find).toHaveBeenCalledWith({ + where: { + tenantId: mockTenantId, + estado: expect.anything(), // In([...]) + }, + order: { createdAt: 'ASC' }, + }); + expect(result).toHaveLength(2); + }); + }); + + describe('getCartasPorteViaje', () => { + it('should return all cartas porte for a specific viaje', async () => { + const result = await service.getCartasPorteViaje(mockViajeId, mockTenantId); + + expect(mockCartaPorteRepository.find).toHaveBeenCalledWith({ + where: { viajeId: mockViajeId, tenantId: mockTenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual([mockCartaPorte]); + }); + }); + + describe('delete', () => { + it('should delete carta porte in BORRADOR state', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + mockCartaPorteRepository.delete = jest.fn().mockResolvedValue({ affected: 1 }); + + const result = await service.delete(mockCartaPorteId, mockTenantId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalled(); + expect(mockCartaPorteRepository.delete).toHaveBeenCalledWith(mockCartaPorteId); + expect(result).toBe(true); + }); + + it('should return false when carta porte not found', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.delete('non-existent-id', mockTenantId); + + expect(result).toBe(false); + }); + + it('should throw error when deleting VALIDADA carta porte', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteValidada); + + await expect( + service.delete(mockCartaPorteId, mockTenantId) + ).rejects.toThrow('Solo se pueden eliminar Cartas Porte en estado BORRADOR'); + }); + + it('should throw error when deleting TIMBRADA carta porte', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteTimbrada); + + await expect( + service.delete(mockCartaPorteId, mockTenantId) + ).rejects.toThrow('Solo se pueden eliminar Cartas Porte en estado BORRADOR'); + }); + + it('should throw error when deleting CANCELADA carta porte', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorteCancelada); + + await expect( + service.delete(mockCartaPorteId, mockTenantId) + ).rejects.toThrow('Solo se pueden eliminar Cartas Porte en estado BORRADOR'); + }); + + it('should return false when delete affects 0 rows', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(mockCartaPorte); + mockCartaPorteRepository.delete = jest.fn().mockResolvedValue({ affected: 0 }); + + const result = await service.delete(mockCartaPorteId, mockTenantId); + + expect(result).toBe(false); + }); + }); + + describe('Tenant Isolation', () => { + const differentTenantId = '550e8400-e29b-41d4-a716-446655440888'; + + it('should not return carta porte from different tenant in findOne', async () => { + mockCartaPorteRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await service.findOne(mockCartaPorteId, differentTenantId); + + expect(mockCartaPorteRepository.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: differentTenantId }), + }) + ); + expect(result).toBeNull(); + }); + + it('should filter by tenantId in findAll', async () => { + await service.findAll({ tenantId: differentTenantId }); + + const callArgs = (mockCartaPorteRepository.findAndCount as jest.Mock).mock.calls[0][0]; + expect(callArgs.where[0].tenantId).toBe(differentTenantId); + }); + + it('should include tenantId when creating carta porte', async () => { + const createDto: CreateCartaPorteDto = { + viajeId: mockViajeId, + tipoCfdi: TipoCfdiCartaPorte.INGRESO, + emisorRfc: 'XAXX010101000', + emisorNombre: 'Transportes SA de CV', + receptorRfc: 'XBXX010101000', + receptorNombre: 'Cliente SA de CV', + }; + + await service.create(differentTenantId, createDto, mockUserId); + + expect(mockCartaPorteRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: differentTenantId }) + ); + }); + }); +}); diff --git a/src/modules/combustible-gastos/services/anticipo-viatico.service.ts b/src/modules/combustible-gastos/services/anticipo-viatico.service.ts new file mode 100644 index 0000000..833e38b --- /dev/null +++ b/src/modules/combustible-gastos/services/anticipo-viatico.service.ts @@ -0,0 +1,386 @@ +import { Repository, FindOptionsWhere, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { AnticipoViatico, EstadoAnticipo } from '../entities/anticipo-viatico.entity.js'; + +/** + * Search parameters for travel advances + */ +export interface AnticipoViaticoSearchParams { + tenantId: string; + viajeId?: string; + operadorId?: string; + estado?: string; + estados?: string[]; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +/** + * DTO for creating a travel advance + */ +export interface CreateAnticipoViaticoDto { + viajeId: string; + operadorId: string; + montoSolicitado: number; + combustibleEstimado?: number; + peajesEstimado?: number; + viaticosEstimado?: number; + observaciones?: string; +} + +/** + * DTO for updating a travel advance + */ +export interface UpdateAnticipoViaticoDto extends Partial { + estado?: string; + montoAprobado?: number; + montoComprobado?: number; + montoReintegro?: number; +} + +/** + * Service for managing travel advances (Anticipos de Viaticos) + */ +export class AnticipoViaticoService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(AnticipoViatico); + } + + /** + * Find all travel advances with filters + */ + async findAll(params: AnticipoViaticoSearchParams): Promise<{ data: AnticipoViatico[]; total: number }> { + const { + tenantId, + viajeId, + operadorId, + estado, + estados, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (viajeId) { + where.viajeId = viajeId; + } + + if (operadorId) { + where.operadorId = operadorId; + } + + if (estado) { + where.estado = estado; + } + + if (estados && estados.length > 0) { + where.estado = In(estados); + } + + if (fechaDesde && fechaHasta) { + where.fechaSolicitud = Between(fechaDesde, fechaHasta); + } + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fechaSolicitud: 'DESC' }, + }); + + return { data, total }; + } + + /** + * Find a single travel advance by ID + */ + async findOne(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Find travel advance by trip + */ + async findByViaje(viajeId: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { viajeId, tenantId }, + }); + } + + /** + * Create a new travel advance + */ + async create(tenantId: string, dto: CreateAnticipoViaticoDto, createdBy: string): Promise { + // Check if advance already exists for trip + const existing = await this.findByViaje(dto.viajeId, tenantId); + if (existing) { + throw new Error('Ya existe un anticipo para este viaje'); + } + + const anticipo = this.repository.create({ + ...dto, + tenantId, + estado: EstadoAnticipo.SOLICITADO, + fechaSolicitud: new Date(), + createdById: createdBy, + }); + + return this.repository.save(anticipo); + } + + /** + * Update an existing travel advance + */ + async update( + tenantId: string, + id: string, + dto: UpdateAnticipoViaticoDto + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + Object.assign(anticipo, dto); + return this.repository.save(anticipo); + } + + /** + * Approve a travel advance + */ + async aprobar( + tenantId: string, + id: string, + montoAprobado: number, + aprobadoPor: string + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.SOLICITADO) { + throw new Error('Solo se pueden aprobar anticipos solicitados'); + } + + anticipo.estado = EstadoAnticipo.APROBADO; + anticipo.montoAprobado = montoAprobado; + anticipo.aprobadoPor = aprobadoPor; + anticipo.fechaAprobacion = new Date(); + + return this.repository.save(anticipo); + } + + /** + * Reject a travel advance + */ + async rechazar( + tenantId: string, + id: string, + aprobadoPor: string, + observaciones?: string + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.SOLICITADO) { + throw new Error('Solo se pueden rechazar anticipos solicitados'); + } + + anticipo.estado = EstadoAnticipo.RECHAZADO; + anticipo.aprobadoPor = aprobadoPor; + anticipo.fechaAprobacion = new Date(); + if (observaciones) { + anticipo.observaciones = observaciones; + } + + return this.repository.save(anticipo); + } + + /** + * Mark advance as delivered + */ + async entregar( + tenantId: string, + id: string, + entregadoPor: string + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.APROBADO) { + throw new Error('Solo se pueden entregar anticipos aprobados'); + } + + anticipo.estado = EstadoAnticipo.ENTREGADO; + anticipo.entregadoPor = entregadoPor; + anticipo.fechaEntrega = new Date(); + + return this.repository.save(anticipo); + } + + /** + * Start accounting process + */ + async iniciarComprobacion(tenantId: string, id: string): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.ENTREGADO) { + throw new Error('Solo se pueden comprobar anticipos entregados'); + } + + anticipo.estado = EstadoAnticipo.COMPROBANDO; + + return this.repository.save(anticipo); + } + + /** + * Update accounted amount + */ + async actualizarComprobado( + tenantId: string, + id: string, + montoComprobado: number + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.COMPROBANDO) { + throw new Error('Solo se pueden actualizar anticipos en comprobacion'); + } + + anticipo.montoComprobado = montoComprobado; + + // Calculate reintegration if over + const aprobado = anticipo.montoAprobado || 0; + if (montoComprobado < aprobado) { + anticipo.montoReintegro = aprobado - montoComprobado; + } + + return this.repository.save(anticipo); + } + + /** + * Liquidate the advance + */ + async liquidar( + tenantId: string, + id: string, + montoComprobadoFinal: number, + liquidadoPor: string + ): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return null; + + if (anticipo.estado !== EstadoAnticipo.COMPROBANDO && anticipo.estado !== EstadoAnticipo.ENTREGADO) { + throw new Error('Solo se pueden liquidar anticipos entregados o en comprobacion'); + } + + anticipo.estado = EstadoAnticipo.LIQUIDADO; + anticipo.montoComprobado = montoComprobadoFinal; + anticipo.liquidadoPor = liquidadoPor; + anticipo.fechaLiquidacion = new Date(); + + // Calculate reintegration + const aprobado = anticipo.montoAprobado || 0; + anticipo.montoReintegro = Math.max(0, aprobado - montoComprobadoFinal); + + return this.repository.save(anticipo); + } + + /** + * Delete a travel advance (only if not delivered) + */ + async delete(tenantId: string, id: string): Promise { + const anticipo = await this.findOne(tenantId, id); + if (!anticipo) return false; + + if (anticipo.estado !== EstadoAnticipo.SOLICITADO && anticipo.estado !== EstadoAnticipo.RECHAZADO) { + throw new Error('Solo se pueden eliminar anticipos solicitados o rechazados'); + } + + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get advances by operator + */ + async getAnticiposPorOperador(operadorId: string, tenantId: string): Promise { + return this.repository.find({ + where: { operadorId, tenantId }, + order: { fechaSolicitud: 'DESC' }, + take: 20, + }); + } + + /** + * Get pending advances (not liquidated) + */ + async getAnticiposPendientes(tenantId: string): Promise { + return this.repository.find({ + where: { + tenantId, + estado: In([ + EstadoAnticipo.SOLICITADO, + EstadoAnticipo.APROBADO, + EstadoAnticipo.ENTREGADO, + EstadoAnticipo.COMPROBANDO, + ]), + }, + order: { fechaSolicitud: 'DESC' }, + }); + } + + /** + * Get advances requiring reintegration + */ + async getAnticiposConReintegro(tenantId: string): Promise { + const qb = this.repository.createQueryBuilder('a'); + qb.where('a.tenant_id = :tenantId', { tenantId }); + qb.andWhere('a.estado = :estado', { estado: EstadoAnticipo.LIQUIDADO }); + qb.andWhere('a.monto_reintegro > 0'); + qb.orderBy('a.fecha_liquidacion', 'DESC'); + + return qb.getMany(); + } + + /** + * Get statistics for a period + */ + async getEstadisticas( + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise<{ + totalSolicitado: number; + totalAprobado: number; + totalComprobado: number; + totalReintegrado: number; + cantidadAnticipos: number; + }> { + const anticipos = await this.repository.find({ + where: { + tenantId, + estado: EstadoAnticipo.LIQUIDADO, + fechaLiquidacion: Between(fechaInicio, fechaFin), + }, + }); + + return { + totalSolicitado: anticipos.reduce((sum, a) => sum + Number(a.montoSolicitado), 0), + totalAprobado: anticipos.reduce((sum, a) => sum + (Number(a.montoAprobado) || 0), 0), + totalComprobado: anticipos.reduce((sum, a) => sum + Number(a.montoComprobado), 0), + totalReintegrado: anticipos.reduce((sum, a) => sum + Number(a.montoReintegro), 0), + cantidadAnticipos: anticipos.length, + }; + } +} + +export const anticipoViaticoService = new AnticipoViaticoService(); diff --git a/src/modules/combustible-gastos/services/carga-combustible.service.ts b/src/modules/combustible-gastos/services/carga-combustible.service.ts new file mode 100644 index 0000000..9f19618 --- /dev/null +++ b/src/modules/combustible-gastos/services/carga-combustible.service.ts @@ -0,0 +1,293 @@ +import { Repository, FindOptionsWhere, ILike, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { CargaCombustible, TipoCargaCombustible, EstadoGasto } from '../entities/carga-combustible.entity.js'; + +/** + * Search parameters for fuel loads + */ +export interface CargaCombustibleSearchParams { + tenantId: string; + search?: string; + unidadId?: string; + viajeId?: string; + operadorId?: string; + tipoCarga?: TipoCargaCombustible; + estado?: EstadoGasto; + estados?: EstadoGasto[]; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +/** + * DTO for creating a fuel load + */ +export interface CreateCargaCombustibleDto { + unidadId: string; + viajeId?: string; + operadorId: string; + tipoCarga: TipoCargaCombustible; + tipoCombustible: string; + litros: number; + precioLitro: number; + total: number; + odometroCarga?: number; + estacionId?: string; + estacionNombre?: string; + estacionDireccion?: string; + latitud?: number; + longitud?: number; + numeroVale?: string; + numeroFactura?: string; + folioTicket?: string; + fechaCarga: Date; + fotoTicketUrl?: string; +} + +/** + * DTO for updating a fuel load + */ +export interface UpdateCargaCombustibleDto extends Partial { + estado?: EstadoGasto; + rendimientoCalculado?: number; +} + +/** + * Service for managing fuel loads (Cargas de Combustible) + */ +export class CargaCombustibleService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(CargaCombustible); + } + + /** + * Find all fuel loads with filters + */ + async findAll(params: CargaCombustibleSearchParams): Promise<{ data: CargaCombustible[]; total: number }> { + const { + tenantId, + search, + unidadId, + viajeId, + operadorId, + tipoCarga, + estado, + estados, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (unidadId) { + baseWhere.unidadId = unidadId; + } + + if (viajeId) { + baseWhere.viajeId = viajeId; + } + + if (operadorId) { + baseWhere.operadorId = operadorId; + } + + if (tipoCarga) { + baseWhere.tipoCarga = tipoCarga; + } + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (fechaDesde && fechaHasta) { + baseWhere.fechaCarga = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, estacionNombre: ILike(`%${search}%`) }, + { ...baseWhere, numeroVale: ILike(`%${search}%`) }, + { ...baseWhere, folioTicket: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fechaCarga: 'DESC' }, + }); + + return { data, total }; + } + + /** + * Find a single fuel load by ID + */ + async findOne(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Create a new fuel load + */ + async create(tenantId: string, dto: CreateCargaCombustibleDto, createdBy: string): Promise { + const carga = this.repository.create({ + ...dto, + tenantId, + estado: EstadoGasto.PENDIENTE, + createdById: createdBy, + }); + + return this.repository.save(carga); + } + + /** + * Update an existing fuel load + */ + async update( + tenantId: string, + id: string, + dto: UpdateCargaCombustibleDto + ): Promise { + const carga = await this.findOne(tenantId, id); + if (!carga) return null; + + Object.assign(carga, dto); + return this.repository.save(carga); + } + + /** + * Approve a fuel load + */ + async aprobar(tenantId: string, id: string, aprobadoPor: string): Promise { + const carga = await this.findOne(tenantId, id); + if (!carga) return null; + + if (carga.estado !== EstadoGasto.PENDIENTE) { + throw new Error('Solo se pueden aprobar cargas pendientes'); + } + + carga.estado = EstadoGasto.APROBADO; + carga.aprobadoPor = aprobadoPor; + carga.aprobadoFecha = new Date(); + + return this.repository.save(carga); + } + + /** + * Reject a fuel load + */ + async rechazar(tenantId: string, id: string, aprobadoPor: string): Promise { + const carga = await this.findOne(tenantId, id); + if (!carga) return null; + + if (carga.estado !== EstadoGasto.PENDIENTE) { + throw new Error('Solo se pueden rechazar cargas pendientes'); + } + + carga.estado = EstadoGasto.RECHAZADO; + carga.aprobadoPor = aprobadoPor; + carga.aprobadoFecha = new Date(); + + return this.repository.save(carga); + } + + /** + * Delete a fuel load (soft or hard) + */ + async delete(tenantId: string, id: string): Promise { + const carga = await this.findOne(tenantId, id); + if (!carga) return false; + + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get fuel loads by unit + */ + async getCargasPorUnidad(unidadId: string, tenantId: string): Promise { + return this.repository.find({ + where: { unidadId, tenantId }, + order: { fechaCarga: 'DESC' }, + take: 50, + }); + } + + /** + * Get fuel loads by trip + */ + async getCargasPorViaje(viajeId: string, tenantId: string): Promise { + return this.repository.find({ + where: { viajeId, tenantId }, + order: { fechaCarga: 'ASC' }, + }); + } + + /** + * Get pending fuel loads + */ + async getCargasPendientes(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, estado: EstadoGasto.PENDIENTE }, + order: { fechaCarga: 'DESC' }, + }); + } + + /** + * Calculate fuel performance for a unit in a period + */ + async calcularRendimiento( + unidadId: string, + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise<{ totalLitros: number; totalKm: number; rendimiento: number }> { + const cargas = await this.repository.find({ + where: { + unidadId, + tenantId, + estado: EstadoGasto.APROBADO, + fechaCarga: Between(fechaInicio, fechaFin), + }, + order: { fechaCarga: 'ASC' }, + }); + + if (cargas.length < 2) { + return { totalLitros: 0, totalKm: 0, rendimiento: 0 }; + } + + let totalLitros = 0; + let kmInicial = cargas[0].odometroCarga || 0; + let kmFinal = kmInicial; + + for (const carga of cargas) { + totalLitros += Number(carga.litros); + if (carga.odometroCarga) { + kmFinal = carga.odometroCarga; + } + } + + const totalKm = kmFinal - kmInicial; + const rendimiento = totalLitros > 0 ? totalKm / totalLitros : 0; + + return { totalLitros, totalKm, rendimiento }; + } +} + +export const cargaCombustibleService = new CargaCombustibleService(); diff --git a/src/modules/combustible-gastos/services/control-rendimiento.service.ts b/src/modules/combustible-gastos/services/control-rendimiento.service.ts new file mode 100644 index 0000000..3342f2f --- /dev/null +++ b/src/modules/combustible-gastos/services/control-rendimiento.service.ts @@ -0,0 +1,356 @@ +import { Repository, FindOptionsWhere, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { ControlRendimiento, TipoAnomaliaRendimiento } from '../entities/control-rendimiento.entity.js'; + +/** + * Search parameters for fuel performance controls + */ +export interface ControlRendimientoSearchParams { + tenantId: string; + unidadId?: string; + tieneAnomalia?: boolean; + tipoAnomalia?: string; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +/** + * DTO for creating a fuel performance control + */ +export interface CreateControlRendimientoDto { + unidadId: string; + fechaInicio: Date; + fechaFin: Date; + kmRecorridos: number; + litrosConsumidos: number; + rendimientoReal: number; + rendimientoEsperado?: number; + variacionPorcentaje?: number; + costoTotalCombustible?: number; + costoPorKm?: number; + tieneAnomalia?: boolean; + tipoAnomalia?: string; + descripcionAnomalia?: string; +} + +/** + * DTO for updating a fuel performance control + */ +export interface UpdateControlRendimientoDto extends Partial {} + +/** + * Service for managing fuel performance controls (Control de Rendimiento) + */ +export class ControlRendimientoService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ControlRendimiento); + } + + /** + * Find all performance controls with filters + */ + async findAll(params: ControlRendimientoSearchParams): Promise<{ data: ControlRendimiento[]; total: number }> { + const { + tenantId, + unidadId, + tieneAnomalia, + tipoAnomalia, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (unidadId) { + where.unidadId = unidadId; + } + + if (tieneAnomalia !== undefined) { + where.tieneAnomalia = tieneAnomalia; + } + + if (tipoAnomalia) { + where.tipoAnomalia = tipoAnomalia; + } + + if (fechaDesde && fechaHasta) { + where.fechaInicio = Between(fechaDesde, fechaHasta); + } + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fechaInicio: 'DESC' }, + }); + + return { data, total }; + } + + /** + * Find a single performance control by ID + */ + async findOne(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Create a new performance control + */ + async create(tenantId: string, dto: CreateControlRendimientoDto): Promise { + // Calculate variation percentage if not provided + if (!dto.variacionPorcentaje && dto.rendimientoEsperado && dto.rendimientoEsperado > 0) { + dto.variacionPorcentaje = ((dto.rendimientoReal - dto.rendimientoEsperado) / dto.rendimientoEsperado) * 100; + } + + // Calculate cost per km if not provided + if (!dto.costoPorKm && dto.costoTotalCombustible && dto.kmRecorridos > 0) { + dto.costoPorKm = dto.costoTotalCombustible / dto.kmRecorridos; + } + + // Detect anomaly if not explicitly set + if (dto.tieneAnomalia === undefined && dto.variacionPorcentaje !== undefined) { + dto.tieneAnomalia = dto.variacionPorcentaje < -15; // 15% below expected is an anomaly + if (dto.tieneAnomalia && !dto.tipoAnomalia) { + dto.tipoAnomalia = TipoAnomaliaRendimiento.BAJO_RENDIMIENTO; + } + } + + const control = this.repository.create({ + ...dto, + tenantId, + }); + + return this.repository.save(control); + } + + /** + * Update an existing performance control + */ + async update( + tenantId: string, + id: string, + dto: UpdateControlRendimientoDto + ): Promise { + const control = await this.findOne(tenantId, id); + if (!control) return null; + + Object.assign(control, dto); + return this.repository.save(control); + } + + /** + * Delete a performance control + */ + async delete(tenantId: string, id: string): Promise { + const control = await this.findOne(tenantId, id); + if (!control) return false; + + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get performance controls by unit + */ + async getControlesPorUnidad(unidadId: string, tenantId: string): Promise { + return this.repository.find({ + where: { unidadId, tenantId }, + order: { fechaInicio: 'DESC' }, + take: 20, + }); + } + + /** + * Get controls with anomalies + */ + async getControlesConAnomalias(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, tieneAnomalia: true }, + order: { fechaInicio: 'DESC' }, + }); + } + + /** + * Get controls by anomaly type + */ + async getControlesPorTipoAnomalia( + tipoAnomalia: string, + tenantId: string + ): Promise { + return this.repository.find({ + where: { tenantId, tipoAnomalia }, + order: { fechaInicio: 'DESC' }, + }); + } + + /** + * Generate performance control for a unit in a period + */ + async generarControlPeriodo( + unidadId: string, + tenantId: string, + fechaInicio: Date, + fechaFin: Date, + rendimientoEsperado: number, + kmRecorridos: number, + litrosConsumidos: number, + costoTotal: number + ): Promise { + const rendimientoReal = litrosConsumidos > 0 ? kmRecorridos / litrosConsumidos : 0; + const variacionPorcentaje = rendimientoEsperado > 0 + ? ((rendimientoReal - rendimientoEsperado) / rendimientoEsperado) * 100 + : 0; + + let tieneAnomalia = false; + let tipoAnomalia: string | undefined; + let descripcionAnomalia: string | undefined; + + // Detect anomalies based on variation + if (variacionPorcentaje < -25) { + tieneAnomalia = true; + tipoAnomalia = TipoAnomaliaRendimiento.CONSUMO_EXCESIVO; + descripcionAnomalia = `Consumo excesivo: ${Math.abs(variacionPorcentaje).toFixed(1)}% por encima del esperado`; + } else if (variacionPorcentaje < -15) { + tieneAnomalia = true; + tipoAnomalia = TipoAnomaliaRendimiento.BAJO_RENDIMIENTO; + descripcionAnomalia = `Bajo rendimiento: ${Math.abs(variacionPorcentaje).toFixed(1)}% por debajo del esperado`; + } + + return this.create(tenantId, { + unidadId, + fechaInicio, + fechaFin, + kmRecorridos, + litrosConsumidos, + rendimientoReal, + rendimientoEsperado, + variacionPorcentaje, + costoTotalCombustible: costoTotal, + costoPorKm: kmRecorridos > 0 ? costoTotal / kmRecorridos : 0, + tieneAnomalia, + tipoAnomalia, + descripcionAnomalia, + }); + } + + /** + * Get fleet performance statistics + */ + async getEstadisticasFlota( + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise<{ + promedioRendimiento: number; + totalKmRecorridos: number; + totalLitrosConsumidos: number; + costoTotalCombustible: number; + costoPorKmPromedio: number; + cantidadUnidades: number; + unidadesConAnomalia: number; + }> { + const controles = await this.repository.find({ + where: { + tenantId, + fechaInicio: Between(fechaInicio, fechaFin), + }, + }); + + if (controles.length === 0) { + return { + promedioRendimiento: 0, + totalKmRecorridos: 0, + totalLitrosConsumidos: 0, + costoTotalCombustible: 0, + costoPorKmPromedio: 0, + cantidadUnidades: 0, + unidadesConAnomalia: 0, + }; + } + + const totalKmRecorridos = controles.reduce((sum, c) => sum + c.kmRecorridos, 0); + const totalLitrosConsumidos = controles.reduce((sum, c) => sum + Number(c.litrosConsumidos), 0); + const costoTotalCombustible = controles.reduce((sum, c) => sum + (Number(c.costoTotalCombustible) || 0), 0); + const unidadesUnicas = new Set(controles.map(c => c.unidadId)); + const unidadesConAnomalia = new Set(controles.filter(c => c.tieneAnomalia).map(c => c.unidadId)); + + return { + promedioRendimiento: totalLitrosConsumidos > 0 ? totalKmRecorridos / totalLitrosConsumidos : 0, + totalKmRecorridos, + totalLitrosConsumidos, + costoTotalCombustible, + costoPorKmPromedio: totalKmRecorridos > 0 ? costoTotalCombustible / totalKmRecorridos : 0, + cantidadUnidades: unidadesUnicas.size, + unidadesConAnomalia: unidadesConAnomalia.size, + }; + } + + /** + * Get unit ranking by performance + */ + async getRankingRendimiento( + tenantId: string, + fechaInicio: Date, + fechaFin: Date, + limit: number = 10 + ): Promise> { + const controles = await this.repository.find({ + where: { + tenantId, + fechaInicio: Between(fechaInicio, fechaFin), + }, + }); + + // Group by unit + const porUnidad = new Map(); + + for (const control of controles) { + const current = porUnidad.get(control.unidadId) || { sumRendimiento: 0, sumKm: 0, count: 0 }; + current.sumRendimiento += Number(control.rendimientoReal); + current.sumKm += control.kmRecorridos; + current.count++; + porUnidad.set(control.unidadId, current); + } + + // Calculate averages and sort + const ranking = Array.from(porUnidad.entries()) + .map(([unidadId, data]) => ({ + unidadId, + rendimientoPromedio: data.count > 0 ? data.sumRendimiento / data.count : 0, + kmTotales: data.sumKm, + })) + .sort((a, b) => b.rendimientoPromedio - a.rendimientoPromedio) + .slice(0, limit); + + return ranking; + } + + /** + * Mark anomaly as investigated + */ + async marcarAnomaliaInvestigada( + tenantId: string, + id: string, + tipoAnomaliaConfirmado: string, + descripcion: string + ): Promise { + const control = await this.findOne(tenantId, id); + if (!control) return null; + + control.tipoAnomalia = tipoAnomaliaConfirmado; + control.descripcionAnomalia = descripcion; + + return this.repository.save(control); + } +} + +export const controlRendimientoService = new ControlRendimientoService(); diff --git a/src/modules/combustible-gastos/services/cruce-peaje.service.ts b/src/modules/combustible-gastos/services/cruce-peaje.service.ts new file mode 100644 index 0000000..e32857a --- /dev/null +++ b/src/modules/combustible-gastos/services/cruce-peaje.service.ts @@ -0,0 +1,249 @@ +import { Repository, FindOptionsWhere, ILike, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { CrucePeaje, TipoPagoPeaje } from '../entities/cruce-peaje.entity.js'; +import { EstadoGasto } from '../entities/carga-combustible.entity.js'; + +/** + * Search parameters for toll crossings + */ +export interface CrucePeajeSearchParams { + tenantId: string; + search?: string; + unidadId?: string; + viajeId?: string; + operadorId?: string; + tipoPago?: string; + estado?: EstadoGasto; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +/** + * DTO for creating a toll crossing + */ +export interface CreateCrucePeajeDto { + unidadId: string; + viajeId?: string; + operadorId?: string; + casetaNombre: string; + casetaCodigo?: string; + carretera?: string; + monto: number; + tipoPago?: string; + tagNumero?: string; + latitud?: number; + longitud?: number; + fechaCruce: Date; + numeroTicket?: string; + fotoTicketUrl?: string; +} + +/** + * DTO for updating a toll crossing + */ +export interface UpdateCrucePeajeDto extends Partial { + estado?: EstadoGasto; +} + +/** + * Service for managing toll crossings (Cruces de Peaje) + */ +export class CrucePeajeService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(CrucePeaje); + } + + /** + * Find all toll crossings with filters + */ + async findAll(params: CrucePeajeSearchParams): Promise<{ data: CrucePeaje[]; total: number }> { + const { + tenantId, + search, + unidadId, + viajeId, + operadorId, + tipoPago, + estado, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (unidadId) { + baseWhere.unidadId = unidadId; + } + + if (viajeId) { + baseWhere.viajeId = viajeId; + } + + if (operadorId) { + baseWhere.operadorId = operadorId; + } + + if (tipoPago) { + baseWhere.tipoPago = tipoPago; + } + + if (estado) { + baseWhere.estado = estado; + } + + if (fechaDesde && fechaHasta) { + baseWhere.fechaCruce = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, casetaNombre: ILike(`%${search}%`) }, + { ...baseWhere, casetaCodigo: ILike(`%${search}%`) }, + { ...baseWhere, carretera: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fechaCruce: 'DESC' }, + }); + + return { data, total }; + } + + /** + * Find a single toll crossing by ID + */ + async findOne(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Create a new toll crossing + */ + async create(tenantId: string, dto: CreateCrucePeajeDto): Promise { + const cruce = this.repository.create({ + ...dto, + tenantId, + estado: EstadoGasto.APROBADO, // Toll crossings are auto-approved by default + }); + + return this.repository.save(cruce); + } + + /** + * Update an existing toll crossing + */ + async update( + tenantId: string, + id: string, + dto: UpdateCrucePeajeDto + ): Promise { + const cruce = await this.findOne(tenantId, id); + if (!cruce) return null; + + Object.assign(cruce, dto); + return this.repository.save(cruce); + } + + /** + * Delete a toll crossing + */ + async delete(tenantId: string, id: string): Promise { + const cruce = await this.findOne(tenantId, id); + if (!cruce) return false; + + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get toll crossings by unit + */ + async getCrucesPorUnidad(unidadId: string, tenantId: string): Promise { + return this.repository.find({ + where: { unidadId, tenantId }, + order: { fechaCruce: 'DESC' }, + take: 50, + }); + } + + /** + * Get toll crossings by trip + */ + async getCrucesPorViaje(viajeId: string, tenantId: string): Promise { + return this.repository.find({ + where: { viajeId, tenantId }, + order: { fechaCruce: 'ASC' }, + }); + } + + /** + * Calculate total toll cost for a trip + */ + async getTotalPeajesViaje(viajeId: string, tenantId: string): Promise { + const cruces = await this.getCrucesPorViaje(viajeId, tenantId); + return cruces.reduce((sum, cruce) => sum + Number(cruce.monto), 0); + } + + /** + * Get toll statistics by period + */ + async getEstadisticasPeajes( + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise<{ totalCruces: number; totalMonto: number; promedioPorCruce: number }> { + const cruces = await this.repository.find({ + where: { + tenantId, + fechaCruce: Between(fechaInicio, fechaFin), + }, + }); + + const totalCruces = cruces.length; + const totalMonto = cruces.reduce((sum, cruce) => sum + Number(cruce.monto), 0); + const promedioPorCruce = totalCruces > 0 ? totalMonto / totalCruces : 0; + + return { totalCruces, totalMonto, promedioPorCruce }; + } + + /** + * Get crossings by toll booth + */ + async getCrucesPorCaseta( + casetaNombre: string, + tenantId: string, + fechaInicio?: Date, + fechaFin?: Date + ): Promise { + const where: FindOptionsWhere = { + tenantId, + casetaNombre: ILike(`%${casetaNombre}%`), + }; + + if (fechaInicio && fechaFin) { + where.fechaCruce = Between(fechaInicio, fechaFin); + } + + return this.repository.find({ + where, + order: { fechaCruce: 'DESC' }, + }); + } +} + +export const crucePeajeService = new CrucePeajeService(); diff --git a/src/modules/combustible-gastos/services/gasto-viaje.service.ts b/src/modules/combustible-gastos/services/gasto-viaje.service.ts new file mode 100644 index 0000000..9c53ea1 --- /dev/null +++ b/src/modules/combustible-gastos/services/gasto-viaje.service.ts @@ -0,0 +1,324 @@ +import { Repository, FindOptionsWhere, ILike, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { GastoViaje, TipoGasto } from '../entities/gasto-viaje.entity.js'; +import { EstadoGasto } from '../entities/carga-combustible.entity.js'; + +/** + * Search parameters for travel expenses + */ +export interface GastoViajeSearchParams { + tenantId: string; + search?: string; + viajeId?: string; + operadorId?: string; + tipoGasto?: TipoGasto; + estado?: EstadoGasto; + estados?: EstadoGasto[]; + tieneFactura?: boolean; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +/** + * DTO for creating a travel expense + */ +export interface CreateGastoViajeDto { + viajeId: string; + operadorId: string; + tipoGasto: TipoGasto; + descripcion: string; + monto: number; + tieneFactura?: boolean; + numeroFactura?: string; + numeroTicket?: string; + fotoComprobanteUrl?: string; + lugar?: string; + latitud?: number; + longitud?: number; + fechaGasto: Date; +} + +/** + * DTO for updating a travel expense + */ +export interface UpdateGastoViajeDto extends Partial { + estado?: EstadoGasto; + motivoRechazo?: string; +} + +/** + * Service for managing travel expenses (Gastos de Viaje) + */ +export class GastoViajeService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(GastoViaje); + } + + /** + * Find all travel expenses with filters + */ + async findAll(params: GastoViajeSearchParams): Promise<{ data: GastoViaje[]; total: number }> { + const { + tenantId, + search, + viajeId, + operadorId, + tipoGasto, + estado, + estados, + tieneFactura, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (viajeId) { + baseWhere.viajeId = viajeId; + } + + if (operadorId) { + baseWhere.operadorId = operadorId; + } + + if (tipoGasto) { + baseWhere.tipoGasto = tipoGasto; + } + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (tieneFactura !== undefined) { + baseWhere.tieneFactura = tieneFactura; + } + + if (fechaDesde && fechaHasta) { + baseWhere.fechaGasto = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, descripcion: ILike(`%${search}%`) }, + { ...baseWhere, lugar: ILike(`%${search}%`) }, + { ...baseWhere, numeroFactura: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fechaGasto: 'DESC' }, + }); + + return { data, total }; + } + + /** + * Find a single travel expense by ID + */ + async findOne(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Create a new travel expense + */ + async create(tenantId: string, dto: CreateGastoViajeDto, createdBy: string): Promise { + const gasto = this.repository.create({ + ...dto, + tenantId, + estado: EstadoGasto.PENDIENTE, + createdById: createdBy, + }); + + return this.repository.save(gasto); + } + + /** + * Update an existing travel expense + */ + async update( + tenantId: string, + id: string, + dto: UpdateGastoViajeDto + ): Promise { + const gasto = await this.findOne(tenantId, id); + if (!gasto) return null; + + Object.assign(gasto, dto); + return this.repository.save(gasto); + } + + /** + * Approve a travel expense + */ + async aprobar(tenantId: string, id: string, aprobadoPor: string): Promise { + const gasto = await this.findOne(tenantId, id); + if (!gasto) return null; + + if (gasto.estado !== EstadoGasto.PENDIENTE) { + throw new Error('Solo se pueden aprobar gastos pendientes'); + } + + gasto.estado = EstadoGasto.APROBADO; + gasto.aprobadoPor = aprobadoPor; + gasto.aprobadoFecha = new Date(); + + return this.repository.save(gasto); + } + + /** + * Reject a travel expense + */ + async rechazar( + tenantId: string, + id: string, + aprobadoPor: string, + motivoRechazo: string + ): Promise { + const gasto = await this.findOne(tenantId, id); + if (!gasto) return null; + + if (gasto.estado !== EstadoGasto.PENDIENTE) { + throw new Error('Solo se pueden rechazar gastos pendientes'); + } + + gasto.estado = EstadoGasto.RECHAZADO; + gasto.aprobadoPor = aprobadoPor; + gasto.aprobadoFecha = new Date(); + gasto.motivoRechazo = motivoRechazo; + + return this.repository.save(gasto); + } + + /** + * Delete a travel expense + */ + async delete(tenantId: string, id: string): Promise { + const gasto = await this.findOne(tenantId, id); + if (!gasto) return false; + + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get travel expenses by trip + */ + async getGastosPorViaje(viajeId: string, tenantId: string): Promise { + return this.repository.find({ + where: { viajeId, tenantId }, + order: { fechaGasto: 'ASC' }, + }); + } + + /** + * Get travel expenses by operator + */ + async getGastosPorOperador(operadorId: string, tenantId: string): Promise { + return this.repository.find({ + where: { operadorId, tenantId }, + order: { fechaGasto: 'DESC' }, + take: 50, + }); + } + + /** + * Get pending travel expenses + */ + async getGastosPendientes(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, estado: EstadoGasto.PENDIENTE }, + order: { fechaGasto: 'DESC' }, + }); + } + + /** + * Calculate total expenses for a trip + */ + async getTotalGastosViaje( + viajeId: string, + tenantId: string + ): Promise<{ total: number; porTipo: Record }> { + const gastos = await this.getGastosPorViaje(viajeId, tenantId); + + const porTipo: Record = {}; + let total = 0; + + for (const gasto of gastos) { + if (gasto.estado === EstadoGasto.APROBADO || gasto.estado === EstadoGasto.PAGADO) { + total += Number(gasto.monto); + porTipo[gasto.tipoGasto] = (porTipo[gasto.tipoGasto] || 0) + Number(gasto.monto); + } + } + + return { total, porTipo }; + } + + /** + * Get expense statistics by type for a period + */ + async getEstadisticasPorTipo( + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise> { + const gastos = await this.repository.find({ + where: { + tenantId, + estado: In([EstadoGasto.APROBADO, EstadoGasto.PAGADO]), + fechaGasto: Between(fechaInicio, fechaFin), + }, + }); + + const estadisticas: Record = {}; + + for (const gasto of gastos) { + if (!estadisticas[gasto.tipoGasto]) { + estadisticas[gasto.tipoGasto] = { cantidad: 0, total: 0 }; + } + estadisticas[gasto.tipoGasto].cantidad++; + estadisticas[gasto.tipoGasto].total += Number(gasto.monto); + } + + return estadisticas; + } + + /** + * Get deductible (with invoice) expenses + */ + async getGastosDeducibles( + tenantId: string, + fechaInicio: Date, + fechaFin: Date + ): Promise { + return this.repository.find({ + where: { + tenantId, + tieneFactura: true, + estado: In([EstadoGasto.APROBADO, EstadoGasto.PAGADO]), + fechaGasto: Between(fechaInicio, fechaFin), + }, + order: { fechaGasto: 'DESC' }, + }); + } +} + +export const gastoViajeService = new GastoViajeService(); diff --git a/src/modules/combustible-gastos/services/index.ts b/src/modules/combustible-gastos/services/index.ts index db2c345..262ea40 100644 --- a/src/modules/combustible-gastos/services/index.ts +++ b/src/modules/combustible-gastos/services/index.ts @@ -1,8 +1,19 @@ /** * Combustible y Gastos Services + * Module: MAI-012 - Vales combustible, peajes, viaticos, control antifraude */ -// TODO: Implement services -// - combustible.service.ts -// - peajes.service.ts -// - viaticos.service.ts -// - control-consumo.service.ts + +// Carga de Combustible Service +export * from './carga-combustible.service.js'; + +// Cruce de Peaje Service +export * from './cruce-peaje.service.js'; + +// Gasto de Viaje Service +export * from './gasto-viaje.service.js'; + +// Anticipo de Viatico Service +export * from './anticipo-viatico.service.js'; + +// Control de Rendimiento Service +export * from './control-rendimiento.service.js'; diff --git a/src/modules/hr/dto/contract.dto.ts b/src/modules/hr/dto/contract.dto.ts new file mode 100644 index 0000000..c634f34 --- /dev/null +++ b/src/modules/hr/dto/contract.dto.ts @@ -0,0 +1,179 @@ +/** + * Contract DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsNumber, + IsUUID, + MaxLength, + IsDateString, + IsEnum, + Min, + IsInt, +} from 'class-validator'; +import { ContractType, ContractStatus, WageType } from '../entities/contract.entity'; + +export class CreateContractDto { + @IsUUID() + companyId: string; + + @IsUUID() + employeeId: string; + + @IsString() + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + reference?: string; + + @IsOptional() + @IsEnum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']) + contractType?: ContractType; + + @IsOptional() + @IsEnum(['draft', 'active', 'expired', 'terminated', 'cancelled']) + status?: ContractStatus; + + @IsDateString() + dateStart: string; + + @IsOptional() + @IsDateString() + dateEnd?: string; + + @IsOptional() + @IsUUID() + jobPositionId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsNumber() + @Min(0) + wage: number; + + @IsOptional() + @IsEnum(['hourly', 'daily', 'weekly', 'biweekly', 'monthly', 'annual']) + wageType?: WageType; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + @Min(0) + hoursPerWeek?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + scheduleType?: string; + + @IsOptional() + @IsInt() + @Min(0) + trialPeriodMonths?: number; + + @IsOptional() + @IsDateString() + trialDateEnd?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + documentUrl?: string; +} + +export class UpdateContractDto { + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + reference?: string; + + @IsOptional() + @IsEnum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']) + contractType?: ContractType; + + @IsOptional() + @IsEnum(['draft', 'active', 'expired', 'terminated', 'cancelled']) + status?: ContractStatus; + + @IsOptional() + @IsDateString() + dateStart?: string; + + @IsOptional() + @IsDateString() + dateEnd?: string; + + @IsOptional() + @IsUUID() + jobPositionId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + wage?: number; + + @IsOptional() + @IsEnum(['hourly', 'daily', 'weekly', 'biweekly', 'monthly', 'annual']) + wageType?: WageType; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + @Min(0) + hoursPerWeek?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + scheduleType?: string; + + @IsOptional() + @IsInt() + @Min(0) + trialPeriodMonths?: number; + + @IsOptional() + @IsDateString() + trialDateEnd?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + documentUrl?: string; +} + +export class TerminateContractDto { + @IsString() + terminationReason: string; +} diff --git a/src/modules/hr/dto/department.dto.ts b/src/modules/hr/dto/department.dto.ts new file mode 100644 index 0000000..1eb7c93 --- /dev/null +++ b/src/modules/hr/dto/department.dto.ts @@ -0,0 +1,80 @@ +/** + * Department DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsBoolean, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class CreateDepartmentDto { + @IsUUID() + companyId: string; + + @IsString() + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; +} + +export class UpdateDepartmentDto { + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; +} diff --git a/src/modules/hr/dto/employee.dto.ts b/src/modules/hr/dto/employee.dto.ts new file mode 100644 index 0000000..2468de7 --- /dev/null +++ b/src/modules/hr/dto/employee.dto.ts @@ -0,0 +1,204 @@ +/** + * Employee DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsBoolean, + IsEmail, + IsNumber, + IsUUID, + MaxLength, + IsDateString, + IsEnum, + Min, +} from 'class-validator'; +import { EstadoEmpleado, Genero } from '../entities/employee.entity'; + +export class CreateEmployeeDto { + @IsString() + @MaxLength(20) + codigo: string; + + @IsString() + @MaxLength(100) + nombre: string; + + @IsString() + @MaxLength(100) + apellidoPaterno: string; + + @IsOptional() + @IsString() + @MaxLength(100) + apellidoMaterno?: string; + + @IsOptional() + @IsString() + @MaxLength(18) + curp?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + rfc?: string; + + @IsOptional() + @IsString() + @MaxLength(11) + nss?: string; + + @IsOptional() + @IsDateString() + fechaNacimiento?: string; + + @IsOptional() + @IsEnum(['M', 'F']) + genero?: Genero; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + telefono?: string; + + @IsOptional() + @IsString() + direccion?: string; + + @IsDateString() + fechaIngreso: string; + + @IsOptional() + @IsDateString() + fechaBaja?: string; + + @IsOptional() + @IsUUID() + puestoId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + departamento?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + tipoContrato?: string; + + @IsOptional() + @IsNumber() + @Min(0) + salarioDiario?: number; + + @IsOptional() + @IsEnum(['activo', 'inactivo', 'baja']) + estado?: EstadoEmpleado; + + @IsOptional() + @IsString() + @MaxLength(500) + fotoUrl?: string; +} + +export class UpdateEmployeeDto { + @IsOptional() + @IsString() + @MaxLength(20) + codigo?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + nombre?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + apellidoPaterno?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + apellidoMaterno?: string; + + @IsOptional() + @IsString() + @MaxLength(18) + curp?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + rfc?: string; + + @IsOptional() + @IsString() + @MaxLength(11) + nss?: string; + + @IsOptional() + @IsDateString() + fechaNacimiento?: string; + + @IsOptional() + @IsEnum(['M', 'F']) + genero?: Genero; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + telefono?: string; + + @IsOptional() + @IsString() + direccion?: string; + + @IsOptional() + @IsDateString() + fechaIngreso?: string; + + @IsOptional() + @IsDateString() + fechaBaja?: string; + + @IsOptional() + @IsUUID() + puestoId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + departamento?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + tipoContrato?: string; + + @IsOptional() + @IsNumber() + @Min(0) + salarioDiario?: number; + + @IsOptional() + @IsEnum(['activo', 'inactivo', 'baja']) + estado?: EstadoEmpleado; + + @IsOptional() + @IsString() + @MaxLength(500) + fotoUrl?: string; +} diff --git a/src/modules/hr/dto/index.ts b/src/modules/hr/dto/index.ts new file mode 100644 index 0000000..85b85f8 --- /dev/null +++ b/src/modules/hr/dto/index.ts @@ -0,0 +1,12 @@ +/** + * HR DTOs Index + * @module HR + */ + +export * from './employee.dto'; +export * from './department.dto'; +export * from './puesto.dto'; +export * from './contract.dto'; +export * from './leave-type.dto'; +export * from './leave-allocation.dto'; +export * from './leave.dto'; diff --git a/src/modules/hr/dto/leave-allocation.dto.ts b/src/modules/hr/dto/leave-allocation.dto.ts new file mode 100644 index 0000000..d5eca1c --- /dev/null +++ b/src/modules/hr/dto/leave-allocation.dto.ts @@ -0,0 +1,63 @@ +/** + * LeaveAllocation DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsNumber, + IsUUID, + IsDateString, + Min, +} from 'class-validator'; + +export class CreateLeaveAllocationDto { + @IsUUID() + employeeId: string; + + @IsUUID() + leaveTypeId: string; + + @IsNumber() + @Min(0) + daysAllocated: number; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateLeaveAllocationDto { + @IsOptional() + @IsNumber() + @Min(0) + daysAllocated?: number; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AdjustLeaveAllocationDto { + @IsNumber() + adjustment: number; + + @IsOptional() + @IsString() + reason?: string; +} diff --git a/src/modules/hr/dto/leave-type.dto.ts b/src/modules/hr/dto/leave-type.dto.ts new file mode 100644 index 0000000..42ffac7 --- /dev/null +++ b/src/modules/hr/dto/leave-type.dto.ts @@ -0,0 +1,151 @@ +/** + * LeaveType DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsUUID, + MaxLength, + IsEnum, + Min, + Max, + IsInt, +} from 'class-validator'; +import { LeaveTypeCategory, AllocationType } from '../entities/leave-type.entity'; + +export class CreateLeaveTypeDto { + @IsUUID() + companyId: string; + + @IsString() + @MaxLength(255) + name: string; + + @IsString() + @MaxLength(50) + code: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsEnum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']) + leaveCategory?: LeaveTypeCategory; + + @IsOptional() + @IsEnum(['fixed', 'accrual', 'unlimited']) + allocationType?: AllocationType; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + requiresDocument?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + maxDaysPerRequest?: number; + + @IsOptional() + @IsInt() + @Min(0) + maxDaysPerYear?: number; + + @IsOptional() + @IsInt() + @Min(0) + minDaysNotice?: number; + + @IsOptional() + @IsBoolean() + isPaid?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + payPercentage?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateLeaveTypeDto { + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsEnum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']) + leaveCategory?: LeaveTypeCategory; + + @IsOptional() + @IsEnum(['fixed', 'accrual', 'unlimited']) + allocationType?: AllocationType; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + requiresDocument?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + maxDaysPerRequest?: number; + + @IsOptional() + @IsInt() + @Min(0) + maxDaysPerYear?: number; + + @IsOptional() + @IsInt() + @Min(0) + minDaysNotice?: number; + + @IsOptional() + @IsBoolean() + isPaid?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + payPercentage?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/hr/dto/leave.dto.ts b/src/modules/hr/dto/leave.dto.ts new file mode 100644 index 0000000..e85a50d --- /dev/null +++ b/src/modules/hr/dto/leave.dto.ts @@ -0,0 +1,127 @@ +/** + * Leave DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsUUID, + IsDateString, + IsEnum, + Min, +} from 'class-validator'; +import { LeaveStatus, HalfDayType } from '../entities/leave.entity'; + +export class CreateLeaveDto { + @IsUUID() + companyId: string; + + @IsUUID() + employeeId: string; + + @IsUUID() + leaveTypeId: string; + + @IsOptional() + @IsUUID() + allocationId?: string; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsNumber() + @Min(0) + daysRequested: number; + + @IsOptional() + @IsBoolean() + isHalfDay?: boolean; + + @IsOptional() + @IsEnum(['morning', 'afternoon']) + halfDayType?: HalfDayType; + + @IsOptional() + @IsString() + requestReason?: string; + + @IsOptional() + @IsString() + documentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateLeaveDto { + @IsOptional() + @IsUUID() + leaveTypeId?: string; + + @IsOptional() + @IsUUID() + allocationId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + daysRequested?: number; + + @IsOptional() + @IsBoolean() + isHalfDay?: boolean; + + @IsOptional() + @IsEnum(['morning', 'afternoon']) + halfDayType?: HalfDayType; + + @IsOptional() + @IsString() + requestReason?: string; + + @IsOptional() + @IsString() + documentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class SubmitLeaveDto { + @IsOptional() + @IsString() + requestReason?: string; +} + +export class ApproveLeaveDto { + @IsOptional() + @IsString() + notes?: string; +} + +export class RejectLeaveDto { + @IsString() + rejectionReason: string; +} + +export class CancelLeaveDto { + @IsOptional() + @IsString() + cancellationReason?: string; +} diff --git a/src/modules/hr/dto/puesto.dto.ts b/src/modules/hr/dto/puesto.dto.ts new file mode 100644 index 0000000..c21b275 --- /dev/null +++ b/src/modules/hr/dto/puesto.dto.ts @@ -0,0 +1,67 @@ +/** + * Puesto (Position) DTOs + * @module HR + */ + +import { + IsString, + IsOptional, + IsBoolean, + MaxLength, +} from 'class-validator'; + +export class CreatePuestoDto { + @IsString() + @MaxLength(20) + codigo: string; + + @IsString() + @MaxLength(100) + nombre: string; + + @IsOptional() + @IsString() + descripcion?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + nivelRiesgo?: string; + + @IsOptional() + @IsBoolean() + requiereCapacitacionEspecial?: boolean; + + @IsOptional() + @IsBoolean() + activo?: boolean; +} + +export class UpdatePuestoDto { + @IsOptional() + @IsString() + @MaxLength(20) + codigo?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + nombre?: string; + + @IsOptional() + @IsString() + descripcion?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + nivelRiesgo?: string; + + @IsOptional() + @IsBoolean() + requiereCapacitacionEspecial?: boolean; + + @IsOptional() + @IsBoolean() + activo?: boolean; +} diff --git a/src/modules/hr/index.ts b/src/modules/hr/index.ts new file mode 100644 index 0000000..2261852 --- /dev/null +++ b/src/modules/hr/index.ts @@ -0,0 +1,15 @@ +/** + * HR Module Index + * Human Resources Management + * + * @module HR + */ + +// Entities +export * from './entities'; + +// DTOs +export * from './dto'; + +// Services +export * from './services'; diff --git a/src/modules/hr/services/contracts.service.ts b/src/modules/hr/services/contracts.service.ts new file mode 100644 index 0000000..58209ef --- /dev/null +++ b/src/modules/hr/services/contracts.service.ts @@ -0,0 +1,360 @@ +/** + * Contracts Service + * @module HR + */ + +import { Repository, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Between, In } from 'typeorm'; +import { Contract, ContractStatus, ContractType } from '../entities/contract.entity'; +import { CreateContractDto, UpdateContractDto, TerminateContractDto } from '../dto'; + +export interface ContractSearchParams { + tenantId: string; + companyId?: string; + employeeId?: string; + search?: string; + contractType?: ContractType; + status?: ContractStatus; + departmentId?: string; + dateStartFrom?: Date; + dateStartTo?: Date; + expiringWithinDays?: number; + limit?: number; + offset?: number; +} + +export class ContractsService { + constructor(private readonly contractRepository: Repository) {} + + /** + * Find all contracts with filters + */ + async findAll(params: ContractSearchParams): Promise<{ data: Contract[]; total: number }> { + const { + tenantId, + companyId, + employeeId, + search, + contractType, + status, + departmentId, + dateStartFrom, + dateStartTo, + expiringWithinDays, + limit = 50, + offset = 0, + } = params; + + const queryBuilder = this.contractRepository + .createQueryBuilder('contract') + .leftJoinAndSelect('contract.department', 'department') + .where('contract.tenant_id = :tenantId', { tenantId }); + + if (companyId) { + queryBuilder.andWhere('contract.company_id = :companyId', { companyId }); + } + + if (employeeId) { + queryBuilder.andWhere('contract.employee_id = :employeeId', { employeeId }); + } + + if (search) { + queryBuilder.andWhere( + '(contract.name ILIKE :search OR contract.reference ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (contractType) { + queryBuilder.andWhere('contract.contract_type = :contractType', { contractType }); + } + + if (status) { + queryBuilder.andWhere('contract.status = :status', { status }); + } + + if (departmentId) { + queryBuilder.andWhere('contract.department_id = :departmentId', { departmentId }); + } + + if (dateStartFrom) { + queryBuilder.andWhere('contract.date_start >= :dateStartFrom', { dateStartFrom }); + } + + if (dateStartTo) { + queryBuilder.andWhere('contract.date_start <= :dateStartTo', { dateStartTo }); + } + + if (expiringWithinDays) { + const today = new Date(); + const futureDate = new Date(today.getTime() + expiringWithinDays * 24 * 60 * 60 * 1000); + queryBuilder.andWhere('contract.date_end IS NOT NULL'); + queryBuilder.andWhere('contract.date_end BETWEEN :today AND :futureDate', { today, futureDate }); + queryBuilder.andWhere('contract.status = :activeStatus', { activeStatus: 'active' }); + } + + queryBuilder.orderBy('contract.date_start', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + /** + * Find one contract by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.contractRepository.findOne({ + where: { id, tenantId }, + relations: ['department'], + }); + } + + /** + * Find active contract for employee + */ + async findActiveByEmployee(employeeId: string, tenantId: string): Promise { + return this.contractRepository.findOne({ + where: { employeeId, tenantId, status: 'active' }, + relations: ['department'], + }); + } + + /** + * Find all contracts for employee + */ + async findByEmployee(employeeId: string, tenantId: string): Promise { + return this.contractRepository.find({ + where: { employeeId, tenantId }, + relations: ['department'], + order: { dateStart: 'DESC' }, + }); + } + + /** + * Create new contract + */ + async create(tenantId: string, dto: CreateContractDto): Promise { + // Check if employee already has an active contract + if (dto.status === 'active') { + const existingActive = await this.findActiveByEmployee(dto.employeeId, tenantId); + if (existingActive) { + throw new Error('El empleado ya tiene un contrato activo'); + } + } + + const contract = this.contractRepository.create({ + ...dto, + tenantId, + dateStart: new Date(dto.dateStart), + dateEnd: dto.dateEnd ? new Date(dto.dateEnd) : null, + trialDateEnd: dto.trialDateEnd ? new Date(dto.trialDateEnd) : null, + }); + + return this.contractRepository.save(contract); + } + + /** + * Update contract + */ + async update(id: string, tenantId: string, dto: UpdateContractDto): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return null; + + // If activating and employee has another active contract + if (dto.status === 'active' && contract.status !== 'active') { + const existingActive = await this.findActiveByEmployee(contract.employeeId, tenantId); + if (existingActive && existingActive.id !== id) { + throw new Error('El empleado ya tiene un contrato activo'); + } + } + + Object.assign(contract, { + ...dto, + dateStart: dto.dateStart ? new Date(dto.dateStart) : contract.dateStart, + dateEnd: dto.dateEnd ? new Date(dto.dateEnd) : contract.dateEnd, + trialDateEnd: dto.trialDateEnd ? new Date(dto.trialDateEnd) : contract.trialDateEnd, + }); + + return this.contractRepository.save(contract); + } + + /** + * Delete contract (only drafts) + */ + async delete(id: string, tenantId: string): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return false; + + if (contract.status !== 'draft') { + throw new Error('Solo se pueden eliminar contratos en estado borrador'); + } + + const result = await this.contractRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Activate contract + */ + async activate(id: string, tenantId: string): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return null; + + if (contract.status !== 'draft') { + throw new Error('Solo se pueden activar contratos en estado borrador'); + } + + // Check if employee has another active contract + const existingActive = await this.findActiveByEmployee(contract.employeeId, tenantId); + if (existingActive) { + throw new Error('El empleado ya tiene un contrato activo. Termine el contrato actual primero.'); + } + + contract.status = 'active'; + contract.activatedAt = new Date(); + return this.contractRepository.save(contract); + } + + /** + * Terminate contract + */ + async terminate( + id: string, + tenantId: string, + dto: TerminateContractDto, + terminatedBy?: string + ): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return null; + + if (contract.status !== 'active') { + throw new Error('Solo se pueden terminar contratos activos'); + } + + contract.status = 'terminated'; + contract.terminatedAt = new Date(); + contract.terminatedBy = terminatedBy || null; + contract.terminationReason = dto.terminationReason; + + return this.contractRepository.save(contract); + } + + /** + * Mark contract as expired + */ + async markExpired(id: string, tenantId: string): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return null; + + contract.status = 'expired'; + return this.contractRepository.save(contract); + } + + /** + * Cancel contract + */ + async cancel(id: string, tenantId: string): Promise { + const contract = await this.findOne(id, tenantId); + if (!contract) return null; + + if (!['draft', 'active'].includes(contract.status)) { + throw new Error('Solo se pueden cancelar contratos en borrador o activos'); + } + + contract.status = 'cancelled'; + return this.contractRepository.save(contract); + } + + /** + * Get expiring contracts + */ + async getExpiring(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const futureDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + return this.contractRepository.find({ + where: { + tenantId, + status: 'active', + dateEnd: Between(today, futureDate), + }, + relations: ['department'], + order: { dateEnd: 'ASC' }, + }); + } + + /** + * Get contracts in trial period + */ + async getInTrialPeriod(tenantId: string): Promise { + const today = new Date(); + + return this.contractRepository.find({ + where: { + tenantId, + status: 'active', + trialDateEnd: MoreThanOrEqual(today), + }, + relations: ['department'], + order: { trialDateEnd: 'ASC' }, + }); + } + + /** + * Get contract count by status + */ + async getCountByStatus(tenantId: string): Promise> { + const result = await this.contractRepository + .createQueryBuilder('contract') + .select('contract.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('contract.tenant_id = :tenantId', { tenantId }) + .groupBy('contract.status') + .getRawMany(); + + return result.reduce((acc, row) => { + acc[row.status] = parseInt(row.count, 10); + return acc; + }, {} as Record); + } + + /** + * Get contract count by type + */ + async getCountByType(tenantId: string): Promise> { + const result = await this.contractRepository + .createQueryBuilder('contract') + .select('contract.contract_type', 'contractType') + .addSelect('COUNT(*)', 'count') + .where('contract.tenant_id = :tenantId', { tenantId }) + .andWhere('contract.status = :status', { status: 'active' }) + .groupBy('contract.contract_type') + .getRawMany(); + + return result.reduce((acc, row) => { + acc[row.contractType] = parseInt(row.count, 10); + return acc; + }, {} as Record); + } + + /** + * Process expired contracts (batch operation) + */ + async processExpiredContracts(tenantId: string): Promise { + const today = new Date(); + + const result = await this.contractRepository.update( + { + tenantId, + status: 'active', + dateEnd: LessThanOrEqual(today), + }, + { + status: 'expired', + } + ); + + return result.affected ?? 0; + } +} diff --git a/src/modules/hr/services/departments.service.ts b/src/modules/hr/services/departments.service.ts new file mode 100644 index 0000000..c906311 --- /dev/null +++ b/src/modules/hr/services/departments.service.ts @@ -0,0 +1,341 @@ +/** + * Departments Service + * @module HR + */ + +import { Repository, FindOptionsWhere, ILike, IsNull } from 'typeorm'; +import { Department } from '../entities/department.entity'; +import { CreateDepartmentDto, UpdateDepartmentDto } from '../dto'; + +export interface DepartmentSearchParams { + tenantId: string; + companyId?: string; + search?: string; + isActive?: boolean; + parentId?: string | null; + limit?: number; + offset?: number; +} + +export class DepartmentsService { + constructor(private readonly departmentRepository: Repository) {} + + /** + * Find all departments with filters + */ + async findAll(params: DepartmentSearchParams): Promise<{ data: Department[]; total: number }> { + const { + tenantId, + companyId, + search, + isActive, + parentId, + limit = 50, + offset = 0, + } = params; + + const queryBuilder = this.departmentRepository + .createQueryBuilder('department') + .leftJoinAndSelect('department.parent', 'parent') + .leftJoinAndSelect('department.children', 'children') + .where('department.tenant_id = :tenantId', { tenantId }); + + if (companyId) { + queryBuilder.andWhere('department.company_id = :companyId', { companyId }); + } + + if (search) { + queryBuilder.andWhere( + '(department.name ILIKE :search OR department.code ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (isActive !== undefined) { + queryBuilder.andWhere('department.is_active = :isActive', { isActive }); + } + + if (parentId === null) { + queryBuilder.andWhere('department.parent_id IS NULL'); + } else if (parentId) { + queryBuilder.andWhere('department.parent_id = :parentId', { parentId }); + } + + queryBuilder.orderBy('department.name', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + /** + * Find one department by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.departmentRepository.findOne({ + where: { id, tenantId }, + relations: ['parent', 'children'], + }); + } + + /** + * Find department by code + */ + async findByCode(code: string, tenantId: string, companyId: string): Promise { + return this.departmentRepository.findOne({ + where: { code, tenantId, companyId }, + }); + } + + /** + * Create new department + */ + async create(tenantId: string, dto: CreateDepartmentDto): Promise { + // Check for existing code + if (dto.code) { + const existingCode = await this.findByCode(dto.code, tenantId, dto.companyId); + if (existingCode) { + throw new Error('Ya existe un departamento con este codigo'); + } + } + + // Verify parent exists if provided + if (dto.parentId) { + const parent = await this.findOne(dto.parentId, tenantId); + if (!parent) { + throw new Error('Departamento padre no encontrado'); + } + } + + const department = this.departmentRepository.create({ + ...dto, + tenantId, + }); + + return this.departmentRepository.save(department); + } + + /** + * Update department + */ + async update(id: string, tenantId: string, dto: UpdateDepartmentDto): Promise { + const department = await this.findOne(id, tenantId); + if (!department) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== department.code) { + const existing = await this.findByCode(dto.code, tenantId, department.companyId); + if (existing) { + throw new Error('Ya existe un departamento con este codigo'); + } + } + + // Verify parent exists if provided + if (dto.parentId !== undefined) { + if (dto.parentId) { + // Cannot set self as parent + if (dto.parentId === id) { + throw new Error('Un departamento no puede ser su propio padre'); + } + + const parent = await this.findOne(dto.parentId, tenantId); + if (!parent) { + throw new Error('Departamento padre no encontrado'); + } + + // Check for circular reference + if (await this.wouldCreateCircularReference(id, dto.parentId, tenantId)) { + throw new Error('No se puede crear referencia circular en jerarquia'); + } + } + } + + Object.assign(department, dto); + return this.departmentRepository.save(department); + } + + /** + * Check if setting parentId would create a circular reference + */ + private async wouldCreateCircularReference( + departmentId: string, + newParentId: string, + tenantId: string + ): Promise { + let currentId: string | null = newParentId; + + while (currentId) { + if (currentId === departmentId) { + return true; + } + + const parent = await this.departmentRepository.findOne({ + where: { id: currentId, tenantId }, + select: ['parentId'], + }); + + currentId = parent?.parentId || null; + } + + return false; + } + + /** + * Delete department + */ + async delete(id: string, tenantId: string): Promise { + const department = await this.findOne(id, tenantId); + if (!department) return false; + + // Check if has children + const childrenCount = await this.departmentRepository.count({ + where: { parentId: id, tenantId }, + }); + + if (childrenCount > 0) { + throw new Error('No se puede eliminar un departamento con subdepartamentos'); + } + + const result = await this.departmentRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get root departments (no parent) + */ + async getRootDepartments(tenantId: string, companyId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + parentId: IsNull(), + isActive: true, + }; + + if (companyId) { + where.companyId = companyId; + } + + return this.departmentRepository.find({ + where, + relations: ['children'], + order: { name: 'ASC' }, + }); + } + + /** + * Get department hierarchy as tree + */ + async getHierarchy(tenantId: string, companyId?: string): Promise { + const roots = await this.getRootDepartments(tenantId, companyId); + return this.buildTreeWithChildren(roots, tenantId); + } + + /** + * Build tree recursively with children + */ + private async buildTreeWithChildren( + departments: Department[], + tenantId: string + ): Promise { + for (const dept of departments) { + if (dept.children && dept.children.length > 0) { + dept.children = await this.buildTreeWithChildren(dept.children, tenantId); + } + } + return departments; + } + + /** + * Get all children of a department (recursive) + */ + async getChildren(id: string, tenantId: string, recursive: boolean = false): Promise { + const direct = await this.departmentRepository.find({ + where: { parentId: id, tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + + if (!recursive) { + return direct; + } + + const allChildren: Department[] = [...direct]; + for (const child of direct) { + const grandChildren = await this.getChildren(child.id, tenantId, true); + allChildren.push(...grandChildren); + } + + return allChildren; + } + + /** + * Get all parent departments (ancestors) + */ + async getAncestors(id: string, tenantId: string): Promise { + const ancestors: Department[] = []; + let current = await this.findOne(id, tenantId); + + while (current?.parentId) { + const parent = await this.findOne(current.parentId, tenantId); + if (parent) { + ancestors.push(parent); + current = parent; + } else { + break; + } + } + + return ancestors.reverse(); // Return from root to immediate parent + } + + /** + * Activate department + */ + async activate(id: string, tenantId: string): Promise { + const department = await this.findOne(id, tenantId); + if (!department) return null; + + department.isActive = true; + return this.departmentRepository.save(department); + } + + /** + * Deactivate department + */ + async deactivate(id: string, tenantId: string): Promise { + const department = await this.findOne(id, tenantId); + if (!department) return null; + + department.isActive = false; + return this.departmentRepository.save(department); + } + + /** + * Set department manager + */ + async setManager(id: string, tenantId: string, managerId: string | null): Promise { + const department = await this.findOne(id, tenantId); + if (!department) return null; + + department.managerId = managerId; + return this.departmentRepository.save(department); + } + + /** + * Get active departments + */ + async getActive(tenantId: string, companyId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + isActive: true, + }; + + if (companyId) { + where.companyId = companyId; + } + + return this.departmentRepository.find({ + where, + order: { name: 'ASC' }, + }); + } +} diff --git a/src/modules/hr/services/employees.service.ts b/src/modules/hr/services/employees.service.ts new file mode 100644 index 0000000..290399d --- /dev/null +++ b/src/modules/hr/services/employees.service.ts @@ -0,0 +1,331 @@ +/** + * Employees Service + * @module HR + */ + +import { Repository, FindOptionsWhere, ILike, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Employee } from '../entities/employee.entity'; +import { CreateEmployeeDto, UpdateEmployeeDto } from '../dto'; + +export interface EmployeeSearchParams { + tenantId: string; + search?: string; + estado?: 'activo' | 'inactivo' | 'baja'; + puestoId?: string; + departamento?: string; + fechaIngresoDesde?: Date; + fechaIngresoHasta?: Date; + limit?: number; + offset?: number; +} + +export class EmployeesService { + constructor(private readonly employeeRepository: Repository) {} + + /** + * Find all employees with filters + */ + async findAll(params: EmployeeSearchParams): Promise<{ data: Employee[]; total: number }> { + const { + tenantId, + search, + estado, + puestoId, + departamento, + fechaIngresoDesde, + fechaIngresoHasta, + limit = 50, + offset = 0, + } = params; + + const queryBuilder = this.employeeRepository + .createQueryBuilder('employee') + .leftJoinAndSelect('employee.puesto', 'puesto') + .where('employee.tenant_id = :tenantId', { tenantId }); + + if (search) { + queryBuilder.andWhere( + '(employee.codigo ILIKE :search OR employee.nombre ILIKE :search OR ' + + 'employee.apellido_paterno ILIKE :search OR employee.apellido_materno ILIKE :search OR ' + + 'employee.curp ILIKE :search OR employee.rfc ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (estado) { + queryBuilder.andWhere('employee.estado = :estado', { estado }); + } + + if (puestoId) { + queryBuilder.andWhere('employee.puesto_id = :puestoId', { puestoId }); + } + + if (departamento) { + queryBuilder.andWhere('employee.departamento ILIKE :departamento', { departamento: `%${departamento}%` }); + } + + if (fechaIngresoDesde) { + queryBuilder.andWhere('employee.fecha_ingreso >= :fechaIngresoDesde', { fechaIngresoDesde }); + } + + if (fechaIngresoHasta) { + queryBuilder.andWhere('employee.fecha_ingreso <= :fechaIngresoHasta', { fechaIngresoHasta }); + } + + queryBuilder + .orderBy('employee.apellido_paterno', 'ASC') + .addOrderBy('employee.apellido_materno', 'ASC') + .addOrderBy('employee.nombre', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + /** + * Find one employee by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.employeeRepository.findOne({ + where: { id, tenantId }, + relations: ['puesto'], + }); + } + + /** + * Find employee by code + */ + async findByCode(codigo: string, tenantId: string): Promise { + return this.employeeRepository.findOne({ + where: { codigo, tenantId }, + relations: ['puesto'], + }); + } + + /** + * Find employee by CURP + */ + async findByCurp(curp: string, tenantId: string): Promise { + return this.employeeRepository.findOne({ + where: { curp, tenantId }, + }); + } + + /** + * Find employee by RFC + */ + async findByRfc(rfc: string, tenantId: string): Promise { + return this.employeeRepository.findOne({ + where: { rfc, tenantId }, + }); + } + + /** + * Create new employee + */ + async create(tenantId: string, dto: CreateEmployeeDto, createdById?: string): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.codigo, tenantId); + if (existingCode) { + throw new Error('Ya existe un empleado con este codigo'); + } + + // Check for existing CURP + if (dto.curp) { + const existingCurp = await this.findByCurp(dto.curp, tenantId); + if (existingCurp) { + throw new Error('Ya existe un empleado con este CURP'); + } + } + + const employee = this.employeeRepository.create({ + ...dto, + tenantId, + createdById, + fechaNacimiento: dto.fechaNacimiento ? new Date(dto.fechaNacimiento) : undefined, + fechaIngreso: new Date(dto.fechaIngreso), + fechaBaja: dto.fechaBaja ? new Date(dto.fechaBaja) : undefined, + }); + + return this.employeeRepository.save(employee); + } + + /** + * Update employee + */ + async update(id: string, tenantId: string, dto: UpdateEmployeeDto): Promise { + const employee = await this.findOne(id, tenantId); + if (!employee) return null; + + // If changing code, check for duplicates + if (dto.codigo && dto.codigo !== employee.codigo) { + const existing = await this.findByCode(dto.codigo, tenantId); + if (existing) { + throw new Error('Ya existe un empleado con este codigo'); + } + } + + // If changing CURP, check for duplicates + if (dto.curp && dto.curp !== employee.curp) { + const existing = await this.findByCurp(dto.curp, tenantId); + if (existing && existing.id !== id) { + throw new Error('Ya existe un empleado con este CURP'); + } + } + + Object.assign(employee, { + ...dto, + fechaNacimiento: dto.fechaNacimiento ? new Date(dto.fechaNacimiento) : employee.fechaNacimiento, + fechaIngreso: dto.fechaIngreso ? new Date(dto.fechaIngreso) : employee.fechaIngreso, + fechaBaja: dto.fechaBaja ? new Date(dto.fechaBaja) : employee.fechaBaja, + }); + + return this.employeeRepository.save(employee); + } + + /** + * Delete employee (soft delete) + */ + async delete(id: string, tenantId: string): Promise { + const employee = await this.findOne(id, tenantId); + if (!employee) return false; + + // Instead of hard delete, mark as 'baja' + employee.estado = 'baja'; + employee.fechaBaja = new Date(); + await this.employeeRepository.save(employee); + + return true; + } + + /** + * Get active employees + */ + async getActive(tenantId: string): Promise { + return this.employeeRepository.find({ + where: { tenantId, estado: 'activo' }, + relations: ['puesto'], + order: { apellidoPaterno: 'ASC', apellidoMaterno: 'ASC', nombre: 'ASC' }, + }); + } + + /** + * Get employees by department + */ + async getByDepartment(tenantId: string, departamento: string): Promise { + return this.employeeRepository.find({ + where: { tenantId, departamento, estado: 'activo' }, + relations: ['puesto'], + order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, + }); + } + + /** + * Get employees by position + */ + async getByPuesto(tenantId: string, puestoId: string): Promise { + return this.employeeRepository.find({ + where: { tenantId, puestoId, estado: 'activo' }, + order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, + }); + } + + /** + * Get employees with upcoming birthdays + */ + async getUpcomingBirthdays(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const endDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + // This query is complex due to year-agnostic birthday matching + // Simplified version - get all active employees with birthdays + return this.employeeRepository + .createQueryBuilder('employee') + .where('employee.tenant_id = :tenantId', { tenantId }) + .andWhere('employee.estado = :estado', { estado: 'activo' }) + .andWhere('employee.fecha_nacimiento IS NOT NULL') + .andWhere( + `(EXTRACT(MONTH FROM employee.fecha_nacimiento) * 100 + EXTRACT(DAY FROM employee.fecha_nacimiento)) BETWEEN ` + + `(EXTRACT(MONTH FROM CURRENT_DATE) * 100 + EXTRACT(DAY FROM CURRENT_DATE)) AND ` + + `(EXTRACT(MONTH FROM (CURRENT_DATE + :days * INTERVAL '1 day')) * 100 + EXTRACT(DAY FROM (CURRENT_DATE + :days * INTERVAL '1 day')))`, + { days: daysAhead } + ) + .orderBy('EXTRACT(MONTH FROM employee.fecha_nacimiento)', 'ASC') + .addOrderBy('EXTRACT(DAY FROM employee.fecha_nacimiento)', 'ASC') + .getMany(); + } + + /** + * Get employees with expiring documents (within days) + */ + async getWithExpiringContracts(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const endDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + // Note: This would need contract entity relation for full implementation + // Simplified to return employees with tipoContrato 'temporal' as a placeholder + return this.employeeRepository.find({ + where: { + tenantId, + estado: 'activo', + tipoContrato: 'temporal', + }, + relations: ['puesto'], + order: { apellidoPaterno: 'ASC' }, + }); + } + + /** + * Get employee count by status + */ + async getCountByStatus(tenantId: string): Promise> { + const result = await this.employeeRepository + .createQueryBuilder('employee') + .select('employee.estado', 'estado') + .addSelect('COUNT(*)', 'count') + .where('employee.tenant_id = :tenantId', { tenantId }) + .groupBy('employee.estado') + .getRawMany(); + + return result.reduce((acc, row) => { + acc[row.estado] = parseInt(row.count, 10); + return acc; + }, {} as Record); + } + + /** + * Activate employee + */ + async activate(id: string, tenantId: string): Promise { + const employee = await this.findOne(id, tenantId); + if (!employee) return null; + + employee.estado = 'activo'; + employee.fechaBaja = null as unknown as Date; + return this.employeeRepository.save(employee); + } + + /** + * Deactivate employee + */ + async deactivate(id: string, tenantId: string): Promise { + const employee = await this.findOne(id, tenantId); + if (!employee) return null; + + employee.estado = 'inactivo'; + return this.employeeRepository.save(employee); + } + + /** + * Terminate employee (dar de baja) + */ + async terminate(id: string, tenantId: string, fechaBaja?: Date): Promise { + const employee = await this.findOne(id, tenantId); + if (!employee) return null; + + employee.estado = 'baja'; + employee.fechaBaja = fechaBaja || new Date(); + return this.employeeRepository.save(employee); + } +} diff --git a/src/modules/hr/services/index.ts b/src/modules/hr/services/index.ts new file mode 100644 index 0000000..b2e16ba --- /dev/null +++ b/src/modules/hr/services/index.ts @@ -0,0 +1,12 @@ +/** + * HR Services Index + * @module HR + */ + +export * from './employees.service'; +export * from './departments.service'; +export * from './puestos.service'; +export * from './contracts.service'; +export * from './leave-types.service'; +export * from './leave-allocations.service'; +export * from './leaves.service'; diff --git a/src/modules/hr/services/leave-allocations.service.ts b/src/modules/hr/services/leave-allocations.service.ts new file mode 100644 index 0000000..1d089b4 --- /dev/null +++ b/src/modules/hr/services/leave-allocations.service.ts @@ -0,0 +1,390 @@ +/** + * LeaveAllocations Service + * @module HR + */ + +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { LeaveAllocation } from '../entities/leave-allocation.entity'; +import { CreateLeaveAllocationDto, UpdateLeaveAllocationDto, AdjustLeaveAllocationDto } from '../dto'; + +export interface LeaveAllocationSearchParams { + tenantId: string; + employeeId?: string; + leaveTypeId?: string; + dateFrom?: Date; + dateTo?: Date; + hasRemainingDays?: boolean; + limit?: number; + offset?: number; +} + +export class LeaveAllocationsService { + constructor(private readonly allocationRepository: Repository) {} + + /** + * Find all allocations with filters + */ + async findAll(params: LeaveAllocationSearchParams): Promise<{ data: LeaveAllocation[]; total: number }> { + const { + tenantId, + employeeId, + leaveTypeId, + dateFrom, + dateTo, + hasRemainingDays, + limit = 50, + offset = 0, + } = params; + + const queryBuilder = this.allocationRepository + .createQueryBuilder('allocation') + .leftJoinAndSelect('allocation.leaveType', 'leaveType') + .where('allocation.tenant_id = :tenantId', { tenantId }); + + if (employeeId) { + queryBuilder.andWhere('allocation.employee_id = :employeeId', { employeeId }); + } + + if (leaveTypeId) { + queryBuilder.andWhere('allocation.leave_type_id = :leaveTypeId', { leaveTypeId }); + } + + if (dateFrom) { + queryBuilder.andWhere('allocation.date_to >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('allocation.date_from <= :dateTo', { dateTo }); + } + + if (hasRemainingDays !== undefined) { + if (hasRemainingDays) { + queryBuilder.andWhere('allocation.days_remaining > 0'); + } else { + queryBuilder.andWhere('allocation.days_remaining <= 0'); + } + } + + queryBuilder.orderBy('allocation.date_from', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + /** + * Find one allocation by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.allocationRepository.findOne({ + where: { id, tenantId }, + relations: ['leaveType'], + }); + } + + /** + * Find active allocation for employee and leave type + */ + async findActiveAllocation( + employeeId: string, + leaveTypeId: string, + tenantId: string, + date?: Date + ): Promise { + const targetDate = date || new Date(); + + return this.allocationRepository.findOne({ + where: { + employeeId, + leaveTypeId, + tenantId, + dateFrom: LessThanOrEqual(targetDate), + dateTo: MoreThanOrEqual(targetDate), + }, + relations: ['leaveType'], + }); + } + + /** + * Find all allocations for employee + */ + async findByEmployee(employeeId: string, tenantId: string): Promise { + return this.allocationRepository.find({ + where: { employeeId, tenantId }, + relations: ['leaveType'], + order: { dateFrom: 'DESC' }, + }); + } + + /** + * Find current allocations for employee + */ + async findCurrentByEmployee(employeeId: string, tenantId: string): Promise { + const today = new Date(); + + return this.allocationRepository.find({ + where: { + employeeId, + tenantId, + dateFrom: LessThanOrEqual(today), + dateTo: MoreThanOrEqual(today), + }, + relations: ['leaveType'], + order: { dateFrom: 'DESC' }, + }); + } + + /** + * Create new allocation + */ + async create(tenantId: string, dto: CreateLeaveAllocationDto): Promise { + // Check for overlapping allocation + const dateFrom = new Date(dto.dateFrom); + const dateTo = new Date(dto.dateTo); + + const overlapping = await this.allocationRepository.findOne({ + where: { + employeeId: dto.employeeId, + leaveTypeId: dto.leaveTypeId, + tenantId, + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + + if (overlapping) { + throw new Error('Ya existe una asignacion para este tipo de ausencia en el periodo especificado'); + } + + const allocation = this.allocationRepository.create({ + ...dto, + tenantId, + dateFrom, + dateTo, + daysUsed: 0, + }); + + return this.allocationRepository.save(allocation); + } + + /** + * Update allocation + */ + async update(id: string, tenantId: string, dto: UpdateLeaveAllocationDto): Promise { + const allocation = await this.findOne(id, tenantId); + if (!allocation) return null; + + // If changing dates, check for overlapping + if (dto.dateFrom || dto.dateTo) { + const dateFrom = dto.dateFrom ? new Date(dto.dateFrom) : allocation.dateFrom; + const dateTo = dto.dateTo ? new Date(dto.dateTo) : allocation.dateTo; + + const overlapping = await this.allocationRepository.findOne({ + where: { + employeeId: allocation.employeeId, + leaveTypeId: allocation.leaveTypeId, + tenantId, + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + + if (overlapping && overlapping.id !== id) { + throw new Error('Ya existe una asignacion para este tipo de ausencia en el periodo especificado'); + } + } + + Object.assign(allocation, { + ...dto, + dateFrom: dto.dateFrom ? new Date(dto.dateFrom) : allocation.dateFrom, + dateTo: dto.dateTo ? new Date(dto.dateTo) : allocation.dateTo, + }); + + return this.allocationRepository.save(allocation); + } + + /** + * Delete allocation + */ + async delete(id: string, tenantId: string): Promise { + const allocation = await this.findOne(id, tenantId); + if (!allocation) return false; + + // Cannot delete if days have been used + if (allocation.daysUsed > 0) { + throw new Error('No se puede eliminar una asignacion con dias utilizados'); + } + + const result = await this.allocationRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Adjust allocated days + */ + async adjustDays( + id: string, + tenantId: string, + dto: AdjustLeaveAllocationDto + ): Promise { + const allocation = await this.findOne(id, tenantId); + if (!allocation) return null; + + const newAllocated = Number(allocation.daysAllocated) + dto.adjustment; + + if (newAllocated < Number(allocation.daysUsed)) { + throw new Error('Los dias asignados no pueden ser menores que los dias utilizados'); + } + + allocation.daysAllocated = newAllocated; + if (dto.reason) { + allocation.notes = allocation.notes + ? `${allocation.notes}\n[${new Date().toISOString()}] Ajuste: ${dto.adjustment} dias. Motivo: ${dto.reason}` + : `[${new Date().toISOString()}] Ajuste: ${dto.adjustment} dias. Motivo: ${dto.reason}`; + } + + return this.allocationRepository.save(allocation); + } + + /** + * Use days from allocation (called when leave is approved) + */ + async useDays(id: string, tenantId: string, days: number): Promise { + const allocation = await this.findOne(id, tenantId); + if (!allocation) return null; + + const remaining = Number(allocation.daysAllocated) - Number(allocation.daysUsed); + if (days > remaining) { + throw new Error(`Dias insuficientes. Disponibles: ${remaining}, Solicitados: ${days}`); + } + + allocation.daysUsed = Number(allocation.daysUsed) + days; + return this.allocationRepository.save(allocation); + } + + /** + * Return days to allocation (called when leave is cancelled) + */ + async returnDays(id: string, tenantId: string, days: number): Promise { + const allocation = await this.findOne(id, tenantId); + if (!allocation) return null; + + const newUsed = Number(allocation.daysUsed) - days; + allocation.daysUsed = Math.max(0, newUsed); + + return this.allocationRepository.save(allocation); + } + + /** + * Get remaining days for employee by leave type + */ + async getRemainingDays( + employeeId: string, + leaveTypeId: string, + tenantId: string + ): Promise { + const allocation = await this.findActiveAllocation(employeeId, leaveTypeId, tenantId); + if (!allocation) return 0; + + return Number(allocation.daysAllocated) - Number(allocation.daysUsed); + } + + /** + * Get all remaining days for employee + */ + async getAllRemainingDaysForEmployee( + employeeId: string, + tenantId: string + ): Promise> { + const allocations = await this.findCurrentByEmployee(employeeId, tenantId); + + return allocations.map(allocation => ({ + leaveTypeId: allocation.leaveTypeId, + leaveTypeName: allocation.leaveType?.name || '', + daysRemaining: Number(allocation.daysAllocated) - Number(allocation.daysUsed), + })); + } + + /** + * Get expiring allocations + */ + async getExpiring(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const futureDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + return this.allocationRepository.find({ + where: { + tenantId, + dateTo: Between(today, futureDate), + }, + relations: ['leaveType'], + order: { dateTo: 'ASC' }, + }); + } + + /** + * Get allocations with unused days (expiring soon) + */ + async getUnusedExpiring(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const futureDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + return this.allocationRepository + .createQueryBuilder('allocation') + .leftJoinAndSelect('allocation.leaveType', 'leaveType') + .where('allocation.tenant_id = :tenantId', { tenantId }) + .andWhere('allocation.date_to BETWEEN :today AND :futureDate', { today, futureDate }) + .andWhere('allocation.days_remaining > 0') + .orderBy('allocation.date_to', 'ASC') + .getMany(); + } + + /** + * Bulk create allocations for multiple employees + */ + async bulkCreate( + tenantId: string, + employeeIds: string[], + leaveTypeId: string, + daysAllocated: number, + dateFrom: Date, + dateTo: Date, + notes?: string + ): Promise { + const allocations: LeaveAllocation[] = []; + + for (const employeeId of employeeIds) { + // Check for overlapping + const overlapping = await this.allocationRepository.findOne({ + where: { + employeeId, + leaveTypeId, + tenantId, + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + + if (!overlapping) { + const allocation = this.allocationRepository.create({ + employeeId, + leaveTypeId, + tenantId, + daysAllocated, + daysUsed: 0, + dateFrom, + dateTo, + notes, + }); + allocations.push(allocation); + } + } + + if (allocations.length > 0) { + return this.allocationRepository.save(allocations); + } + + return []; + } +} diff --git a/src/modules/hr/services/leave-types.service.ts b/src/modules/hr/services/leave-types.service.ts new file mode 100644 index 0000000..74783a0 --- /dev/null +++ b/src/modules/hr/services/leave-types.service.ts @@ -0,0 +1,298 @@ +/** + * LeaveTypes Service + * @module HR + */ + +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { LeaveType, LeaveTypeCategory, AllocationType } from '../entities/leave-type.entity'; +import { CreateLeaveTypeDto, UpdateLeaveTypeDto } from '../dto'; + +export interface LeaveTypeSearchParams { + tenantId: string; + companyId?: string; + search?: string; + leaveCategory?: LeaveTypeCategory; + allocationType?: AllocationType; + isActive?: boolean; + requiresApproval?: boolean; + isPaid?: boolean; + limit?: number; + offset?: number; +} + +export class LeaveTypesService { + constructor(private readonly leaveTypeRepository: Repository) {} + + /** + * Find all leave types with filters + */ + async findAll(params: LeaveTypeSearchParams): Promise<{ data: LeaveType[]; total: number }> { + const { + tenantId, + companyId, + search, + leaveCategory, + allocationType, + isActive, + requiresApproval, + isPaid, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (companyId) { + baseWhere.companyId = companyId; + } + + if (leaveCategory) { + baseWhere.leaveCategory = leaveCategory; + } + + if (allocationType) { + baseWhere.allocationType = allocationType; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (requiresApproval !== undefined) { + baseWhere.requiresApproval = requiresApproval; + } + + if (isPaid !== undefined) { + baseWhere.isPaid = isPaid; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.leaveTypeRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + /** + * Find one leave type by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.leaveTypeRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Find leave type by code + */ + async findByCode(code: string, tenantId: string, companyId: string): Promise { + return this.leaveTypeRepository.findOne({ + where: { code, tenantId, companyId }, + }); + } + + /** + * Create new leave type + */ + async create(tenantId: string, dto: CreateLeaveTypeDto): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId, dto.companyId); + if (existingCode) { + throw new Error('Ya existe un tipo de ausencia con este codigo'); + } + + const leaveType = this.leaveTypeRepository.create({ + ...dto, + tenantId, + }); + + return this.leaveTypeRepository.save(leaveType); + } + + /** + * Update leave type + */ + async update(id: string, tenantId: string, dto: UpdateLeaveTypeDto): Promise { + const leaveType = await this.findOne(id, tenantId); + if (!leaveType) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== leaveType.code) { + const existing = await this.findByCode(dto.code, tenantId, leaveType.companyId); + if (existing) { + throw new Error('Ya existe un tipo de ausencia con este codigo'); + } + } + + Object.assign(leaveType, dto); + return this.leaveTypeRepository.save(leaveType); + } + + /** + * Delete leave type + */ + async delete(id: string, tenantId: string): Promise { + const leaveType = await this.findOne(id, tenantId); + if (!leaveType) return false; + + // Check if has leaves or allocations associated + const leaveTypeWithRelations = await this.leaveTypeRepository.findOne({ + where: { id, tenantId }, + relations: ['leaves', 'allocations'], + }); + + if (leaveTypeWithRelations?.leaves && leaveTypeWithRelations.leaves.length > 0) { + throw new Error('No se puede eliminar un tipo de ausencia con solicitudes asociadas'); + } + + if (leaveTypeWithRelations?.allocations && leaveTypeWithRelations.allocations.length > 0) { + throw new Error('No se puede eliminar un tipo de ausencia con asignaciones asociadas'); + } + + const result = await this.leaveTypeRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get active leave types + */ + async getActive(tenantId: string, companyId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + isActive: true, + }; + + if (companyId) { + where.companyId = companyId; + } + + return this.leaveTypeRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + /** + * Get leave types by category + */ + async getByCategory(tenantId: string, leaveCategory: LeaveTypeCategory): Promise { + return this.leaveTypeRepository.find({ + where: { tenantId, leaveCategory, isActive: true }, + order: { name: 'ASC' }, + }); + } + + /** + * Get paid leave types + */ + async getPaidTypes(tenantId: string, companyId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + isPaid: true, + isActive: true, + }; + + if (companyId) { + where.companyId = companyId; + } + + return this.leaveTypeRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + /** + * Get unpaid leave types + */ + async getUnpaidTypes(tenantId: string, companyId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + isPaid: false, + isActive: true, + }; + + if (companyId) { + where.companyId = companyId; + } + + return this.leaveTypeRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + /** + * Get leave types requiring approval + */ + async getRequiringApproval(tenantId: string): Promise { + return this.leaveTypeRepository.find({ + where: { tenantId, requiresApproval: true, isActive: true }, + order: { name: 'ASC' }, + }); + } + + /** + * Get leave types requiring documentation + */ + async getRequiringDocument(tenantId: string): Promise { + return this.leaveTypeRepository.find({ + where: { tenantId, requiresDocument: true, isActive: true }, + order: { name: 'ASC' }, + }); + } + + /** + * Activate leave type + */ + async activate(id: string, tenantId: string): Promise { + const leaveType = await this.findOne(id, tenantId); + if (!leaveType) return null; + + leaveType.isActive = true; + return this.leaveTypeRepository.save(leaveType); + } + + /** + * Deactivate leave type + */ + async deactivate(id: string, tenantId: string): Promise { + const leaveType = await this.findOne(id, tenantId); + if (!leaveType) return null; + + leaveType.isActive = false; + return this.leaveTypeRepository.save(leaveType); + } + + /** + * Get count by category + */ + async getCountByCategory(tenantId: string): Promise> { + const result = await this.leaveTypeRepository + .createQueryBuilder('leaveType') + .select('leaveType.leave_category', 'category') + .addSelect('COUNT(*)', 'count') + .where('leaveType.tenant_id = :tenantId', { tenantId }) + .andWhere('leaveType.is_active = true') + .groupBy('leaveType.leave_category') + .getRawMany(); + + return result.reduce((acc, row) => { + acc[row.category] = parseInt(row.count, 10); + return acc; + }, {} as Record); + } +} diff --git a/src/modules/hr/services/leaves.service.ts b/src/modules/hr/services/leaves.service.ts new file mode 100644 index 0000000..ba06758 --- /dev/null +++ b/src/modules/hr/services/leaves.service.ts @@ -0,0 +1,563 @@ +/** + * Leaves Service + * @module HR + */ + +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm'; +import { Leave, LeaveStatus } from '../entities/leave.entity'; +import { LeaveAllocation } from '../entities/leave-allocation.entity'; +import { LeaveType } from '../entities/leave-type.entity'; +import { + CreateLeaveDto, + UpdateLeaveDto, + SubmitLeaveDto, + ApproveLeaveDto, + RejectLeaveDto, + CancelLeaveDto, +} from '../dto'; + +export interface LeaveSearchParams { + tenantId: string; + companyId?: string; + employeeId?: string; + leaveTypeId?: string; + status?: LeaveStatus; + approverId?: string; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export class LeavesService { + constructor( + private readonly leaveRepository: Repository, + private readonly allocationRepository: Repository, + private readonly leaveTypeRepository: Repository + ) {} + + /** + * Find all leaves with filters + */ + async findAll(params: LeaveSearchParams): Promise<{ data: Leave[]; total: number }> { + const { + tenantId, + companyId, + employeeId, + leaveTypeId, + status, + approverId, + dateFrom, + dateTo, + limit = 50, + offset = 0, + } = params; + + const queryBuilder = this.leaveRepository + .createQueryBuilder('leave') + .leftJoinAndSelect('leave.leaveType', 'leaveType') + .leftJoinAndSelect('leave.allocation', 'allocation') + .where('leave.tenant_id = :tenantId', { tenantId }); + + if (companyId) { + queryBuilder.andWhere('leave.company_id = :companyId', { companyId }); + } + + if (employeeId) { + queryBuilder.andWhere('leave.employee_id = :employeeId', { employeeId }); + } + + if (leaveTypeId) { + queryBuilder.andWhere('leave.leave_type_id = :leaveTypeId', { leaveTypeId }); + } + + if (status) { + queryBuilder.andWhere('leave.status = :status', { status }); + } + + if (approverId) { + queryBuilder.andWhere('leave.approver_id = :approverId', { approverId }); + } + + if (dateFrom) { + queryBuilder.andWhere('leave.date_to >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('leave.date_from <= :dateTo', { dateTo }); + } + + queryBuilder.orderBy('leave.date_from', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + /** + * Find one leave by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.leaveRepository.findOne({ + where: { id, tenantId }, + relations: ['leaveType', 'allocation'], + }); + } + + /** + * Find leaves by employee + */ + async findByEmployee(employeeId: string, tenantId: string): Promise { + return this.leaveRepository.find({ + where: { employeeId, tenantId }, + relations: ['leaveType'], + order: { dateFrom: 'DESC' }, + }); + } + + /** + * Find pending leaves for approval + */ + async findPendingApproval(tenantId: string, approverId?: string): Promise { + const where: FindOptionsWhere = { + tenantId, + status: 'submitted', + }; + + if (approverId) { + where.approverId = approverId; + } + + return this.leaveRepository.find({ + where, + relations: ['leaveType'], + order: { submittedAt: 'ASC' }, + }); + } + + /** + * Create new leave request + */ + async create(tenantId: string, dto: CreateLeaveDto): Promise { + const dateFrom = new Date(dto.dateFrom); + const dateTo = new Date(dto.dateTo); + + // Validate date range + if (dateFrom > dateTo) { + throw new Error('La fecha de inicio no puede ser posterior a la fecha de fin'); + } + + // Check for overlapping leaves + const overlapping = await this.leaveRepository.findOne({ + where: { + employeeId: dto.employeeId, + tenantId, + status: In(['draft', 'submitted', 'approved']), + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + + if (overlapping) { + throw new Error('Ya existe una solicitud de ausencia en el periodo especificado'); + } + + // Find allocation if not specified + let allocationId = dto.allocationId; + if (!allocationId) { + const allocation = await this.allocationRepository.findOne({ + where: { + employeeId: dto.employeeId, + leaveTypeId: dto.leaveTypeId, + tenantId, + dateFrom: LessThanOrEqual(dateFrom), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + allocationId = allocation?.id; + } + + const leave = this.leaveRepository.create({ + ...dto, + tenantId, + allocationId: allocationId || null, + dateFrom, + dateTo, + status: 'draft', + }); + + return this.leaveRepository.save(leave); + } + + /** + * Update leave request + */ + async update(id: string, tenantId: string, dto: UpdateLeaveDto): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return null; + + if (leave.status !== 'draft') { + throw new Error('Solo se pueden modificar solicitudes en borrador'); + } + + const dateFrom = dto.dateFrom ? new Date(dto.dateFrom) : leave.dateFrom; + const dateTo = dto.dateTo ? new Date(dto.dateTo) : leave.dateTo; + + // Check for overlapping if dates changed + if (dto.dateFrom || dto.dateTo) { + const overlapping = await this.leaveRepository.findOne({ + where: { + employeeId: leave.employeeId, + tenantId, + status: In(['draft', 'submitted', 'approved']), + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }, + }); + + if (overlapping && overlapping.id !== id) { + throw new Error('Ya existe una solicitud de ausencia en el periodo especificado'); + } + } + + Object.assign(leave, { + ...dto, + dateFrom, + dateTo, + }); + + return this.leaveRepository.save(leave); + } + + /** + * Delete leave request + */ + async delete(id: string, tenantId: string): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return false; + + if (leave.status !== 'draft') { + throw new Error('Solo se pueden eliminar solicitudes en borrador'); + } + + const result = await this.leaveRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Submit leave for approval + */ + async submit(id: string, tenantId: string, dto?: SubmitLeaveDto): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return null; + + if (leave.status !== 'draft') { + throw new Error('Solo se pueden enviar solicitudes en borrador'); + } + + // Validate leave type requirements + const leaveType = await this.leaveTypeRepository.findOne({ + where: { id: leave.leaveTypeId, tenantId }, + }); + + if (leaveType) { + // Check minimum notice + if (leaveType.minDaysNotice > 0) { + const today = new Date(); + const daysUntilLeave = Math.floor( + (leave.dateFrom.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); + if (daysUntilLeave < leaveType.minDaysNotice) { + throw new Error(`Se requieren al menos ${leaveType.minDaysNotice} dias de anticipacion`); + } + } + + // Check max days per request + if (leaveType.maxDaysPerRequest && leave.daysRequested > leaveType.maxDaysPerRequest) { + throw new Error(`El maximo de dias por solicitud es ${leaveType.maxDaysPerRequest}`); + } + + // Check if document is required + if (leaveType.requiresDocument && !leave.documentUrl) { + throw new Error('Este tipo de ausencia requiere documentacion'); + } + } + + // Check allocation has enough days + if (leave.allocationId) { + const allocation = await this.allocationRepository.findOne({ + where: { id: leave.allocationId, tenantId }, + }); + if (allocation) { + const remaining = Number(allocation.daysAllocated) - Number(allocation.daysUsed); + if (remaining < leave.daysRequested) { + throw new Error(`Dias insuficientes. Disponibles: ${remaining}`); + } + } + } + + leave.status = 'submitted'; + leave.submittedAt = new Date(); + if (dto?.requestReason) { + leave.requestReason = dto.requestReason; + } + + return this.leaveRepository.save(leave); + } + + /** + * Approve leave + */ + async approve( + id: string, + tenantId: string, + approverId: string, + dto?: ApproveLeaveDto + ): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return null; + + if (leave.status !== 'submitted') { + throw new Error('Solo se pueden aprobar solicitudes enviadas'); + } + + // Use days from allocation + if (leave.allocationId) { + const allocation = await this.allocationRepository.findOne({ + where: { id: leave.allocationId, tenantId }, + }); + if (allocation) { + const remaining = Number(allocation.daysAllocated) - Number(allocation.daysUsed); + if (remaining < leave.daysRequested) { + throw new Error(`Dias insuficientes en la asignacion. Disponibles: ${remaining}`); + } + allocation.daysUsed = Number(allocation.daysUsed) + Number(leave.daysRequested); + await this.allocationRepository.save(allocation); + } + } + + leave.status = 'approved'; + leave.approverId = approverId; + leave.approvedAt = new Date(); + if (dto?.notes) { + leave.notes = dto.notes; + } + + return this.leaveRepository.save(leave); + } + + /** + * Reject leave + */ + async reject( + id: string, + tenantId: string, + approverId: string, + dto: RejectLeaveDto + ): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return null; + + if (leave.status !== 'submitted') { + throw new Error('Solo se pueden rechazar solicitudes enviadas'); + } + + leave.status = 'rejected'; + leave.approverId = approverId; + leave.rejectionReason = dto.rejectionReason; + + return this.leaveRepository.save(leave); + } + + /** + * Cancel leave + */ + async cancel( + id: string, + tenantId: string, + cancelledBy: string, + dto?: CancelLeaveDto + ): Promise { + const leave = await this.findOne(id, tenantId); + if (!leave) return null; + + if (!['draft', 'submitted', 'approved'].includes(leave.status)) { + throw new Error('Solo se pueden cancelar solicitudes en borrador, enviadas o aprobadas'); + } + + // If was approved, return days to allocation + if (leave.status === 'approved' && leave.allocationId) { + const allocation = await this.allocationRepository.findOne({ + where: { id: leave.allocationId, tenantId }, + }); + if (allocation) { + allocation.daysUsed = Math.max(0, Number(allocation.daysUsed) - Number(leave.daysRequested)); + await this.allocationRepository.save(allocation); + } + } + + leave.status = 'cancelled'; + leave.cancelledAt = new Date(); + leave.cancelledBy = cancelledBy; + if (dto?.cancellationReason) { + leave.notes = leave.notes + ? `${leave.notes}\nCancelacion: ${dto.cancellationReason}` + : `Cancelacion: ${dto.cancellationReason}`; + } + + return this.leaveRepository.save(leave); + } + + /** + * Get leaves by date range + */ + async getByDateRange( + tenantId: string, + dateFrom: Date, + dateTo: Date, + employeeId?: string + ): Promise { + const where: FindOptionsWhere = { + tenantId, + status: 'approved', + dateFrom: LessThanOrEqual(dateTo), + dateTo: MoreThanOrEqual(dateFrom), + }; + + if (employeeId) { + where.employeeId = employeeId; + } + + return this.leaveRepository.find({ + where, + relations: ['leaveType'], + order: { dateFrom: 'ASC' }, + }); + } + + /** + * Get count by status + */ + async getCountByStatus(tenantId: string): Promise> { + const result = await this.leaveRepository + .createQueryBuilder('leave') + .select('leave.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('leave.tenant_id = :tenantId', { tenantId }) + .groupBy('leave.status') + .getRawMany(); + + return result.reduce((acc, row) => { + acc[row.status] = parseInt(row.count, 10); + return acc; + }, {} as Record); + } + + /** + * Get upcoming approved leaves + */ + async getUpcoming(tenantId: string, daysAhead: number = 30): Promise { + const today = new Date(); + const futureDate = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + return this.leaveRepository.find({ + where: { + tenantId, + status: 'approved', + dateFrom: Between(today, futureDate), + }, + relations: ['leaveType'], + order: { dateFrom: 'ASC' }, + }); + } + + /** + * Get employees on leave today + */ + async getOnLeaveToday(tenantId: string): Promise { + const today = new Date(); + + return this.leaveRepository.find({ + where: { + tenantId, + status: 'approved', + dateFrom: LessThanOrEqual(today), + dateTo: MoreThanOrEqual(today), + }, + relations: ['leaveType'], + }); + } + + /** + * Check if employee is on leave on a specific date + */ + async isOnLeave(employeeId: string, tenantId: string, date: Date): Promise { + const leave = await this.leaveRepository.findOne({ + where: { + employeeId, + tenantId, + status: 'approved', + dateFrom: LessThanOrEqual(date), + dateTo: MoreThanOrEqual(date), + }, + }); + + return leave !== null; + } + + /** + * Get leave history for employee + */ + async getEmployeeHistory( + employeeId: string, + tenantId: string, + year?: number + ): Promise { + const queryBuilder = this.leaveRepository + .createQueryBuilder('leave') + .leftJoinAndSelect('leave.leaveType', 'leaveType') + .where('leave.employee_id = :employeeId', { employeeId }) + .andWhere('leave.tenant_id = :tenantId', { tenantId }); + + if (year) { + queryBuilder.andWhere('EXTRACT(YEAR FROM leave.date_from) = :year', { year }); + } + + return queryBuilder.orderBy('leave.date_from', 'DESC').getMany(); + } + + /** + * Get leave summary for employee + */ + async getEmployeeSummary( + employeeId: string, + tenantId: string, + year?: number + ): Promise> { + const queryBuilder = this.leaveRepository + .createQueryBuilder('leave') + .leftJoin('leave.leaveType', 'leaveType') + .select('leave.leave_type_id', 'leaveTypeId') + .addSelect('leaveType.name', 'leaveTypeName') + .addSelect('SUM(leave.days_requested)', 'daysUsed') + .where('leave.employee_id = :employeeId', { employeeId }) + .andWhere('leave.tenant_id = :tenantId', { tenantId }) + .andWhere('leave.status = :status', { status: 'approved' }); + + if (year) { + queryBuilder.andWhere('EXTRACT(YEAR FROM leave.date_from) = :year', { year }); + } + + const result = await queryBuilder + .groupBy('leave.leave_type_id') + .addGroupBy('leaveType.name') + .getRawMany(); + + return result.map(row => ({ + leaveTypeId: row.leaveTypeId, + leaveTypeName: row.leaveTypeName || '', + daysUsed: parseFloat(row.daysUsed) || 0, + })); + } +} diff --git a/src/modules/hr/services/puestos.service.ts b/src/modules/hr/services/puestos.service.ts new file mode 100644 index 0000000..3d26f86 --- /dev/null +++ b/src/modules/hr/services/puestos.service.ts @@ -0,0 +1,217 @@ +/** + * Puestos (Positions) Service + * @module HR + */ + +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Puesto } from '../entities/puesto.entity'; +import { CreatePuestoDto, UpdatePuestoDto } from '../dto'; + +export interface PuestoSearchParams { + tenantId: string; + search?: string; + activo?: boolean; + nivelRiesgo?: string; + requiereCapacitacionEspecial?: boolean; + limit?: number; + offset?: number; +} + +export class PuestosService { + constructor(private readonly puestoRepository: Repository) {} + + /** + * Find all puestos with filters + */ + async findAll(params: PuestoSearchParams): Promise<{ data: Puesto[]; total: number }> { + const { + tenantId, + search, + activo, + nivelRiesgo, + requiereCapacitacionEspecial, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (activo !== undefined) { + baseWhere.activo = activo; + } + + if (nivelRiesgo) { + baseWhere.nivelRiesgo = nivelRiesgo; + } + + if (requiereCapacitacionEspecial !== undefined) { + baseWhere.requiereCapacitacionEspecial = requiereCapacitacionEspecial; + } + + if (search) { + where.push( + { ...baseWhere, codigo: ILike(`%${search}%`) }, + { ...baseWhere, nombre: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.puestoRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { nombre: 'ASC' }, + }); + + return { data, total }; + } + + /** + * Find one puesto by ID + */ + async findOne(id: string, tenantId: string): Promise { + return this.puestoRepository.findOne({ + where: { id, tenantId }, + relations: ['empleados'], + }); + } + + /** + * Find puesto by code + */ + async findByCode(codigo: string, tenantId: string): Promise { + return this.puestoRepository.findOne({ + where: { codigo, tenantId }, + }); + } + + /** + * Create new puesto + */ + async create(tenantId: string, dto: CreatePuestoDto): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.codigo, tenantId); + if (existingCode) { + throw new Error('Ya existe un puesto con este codigo'); + } + + const puesto = this.puestoRepository.create({ + ...dto, + tenantId, + }); + + return this.puestoRepository.save(puesto); + } + + /** + * Update puesto + */ + async update(id: string, tenantId: string, dto: UpdatePuestoDto): Promise { + const puesto = await this.findOne(id, tenantId); + if (!puesto) return null; + + // If changing code, check for duplicates + if (dto.codigo && dto.codigo !== puesto.codigo) { + const existing = await this.findByCode(dto.codigo, tenantId); + if (existing) { + throw new Error('Ya existe un puesto con este codigo'); + } + } + + Object.assign(puesto, dto); + return this.puestoRepository.save(puesto); + } + + /** + * Delete puesto + */ + async delete(id: string, tenantId: string): Promise { + const puesto = await this.findOne(id, tenantId); + if (!puesto) return false; + + // Check if has employees assigned + if (puesto.empleados && puesto.empleados.length > 0) { + throw new Error('No se puede eliminar un puesto con empleados asignados'); + } + + const result = await this.puestoRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Get active puestos + */ + async getActive(tenantId: string): Promise { + return this.puestoRepository.find({ + where: { tenantId, activo: true }, + order: { nombre: 'ASC' }, + }); + } + + /** + * Get puestos by risk level + */ + async getByRiskLevel(tenantId: string, nivelRiesgo: string): Promise { + return this.puestoRepository.find({ + where: { tenantId, nivelRiesgo, activo: true }, + order: { nombre: 'ASC' }, + }); + } + + /** + * Get puestos requiring special training + */ + async getRequiringSpecialTraining(tenantId: string): Promise { + return this.puestoRepository.find({ + where: { tenantId, requiereCapacitacionEspecial: true, activo: true }, + order: { nombre: 'ASC' }, + }); + } + + /** + * Activate puesto + */ + async activate(id: string, tenantId: string): Promise { + const puesto = await this.findOne(id, tenantId); + if (!puesto) return null; + + puesto.activo = true; + return this.puestoRepository.save(puesto); + } + + /** + * Deactivate puesto + */ + async deactivate(id: string, tenantId: string): Promise { + const puesto = await this.findOne(id, tenantId); + if (!puesto) return null; + + puesto.activo = false; + return this.puestoRepository.save(puesto); + } + + /** + * Get employee count by puesto + */ + async getEmployeeCount(tenantId: string): Promise> { + const result = await this.puestoRepository + .createQueryBuilder('puesto') + .leftJoin('puesto.empleados', 'empleado', 'empleado.estado = :estado', { estado: 'activo' }) + .select('puesto.id', 'puestoId') + .addSelect('puesto.nombre', 'nombre') + .addSelect('COUNT(empleado.id)', 'count') + .where('puesto.tenant_id = :tenantId', { tenantId }) + .andWhere('puesto.activo = true') + .groupBy('puesto.id') + .addGroupBy('puesto.nombre') + .getRawMany(); + + return result.map(row => ({ + puestoId: row.puestoId, + nombre: row.nombre, + count: parseInt(row.count, 10), + })); + } +} diff --git a/src/modules/reports/services/custom-report.service.ts b/src/modules/reports/services/custom-report.service.ts new file mode 100644 index 0000000..47de7bb --- /dev/null +++ b/src/modules/reports/services/custom-report.service.ts @@ -0,0 +1,369 @@ +/** + * Custom Report Service + * Manages user-customized reports based on existing definitions + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { CustomReport } from '../entities/custom-report.entity'; + +export interface CustomReportSearchParams { + tenantId: string; + ownerId?: string; + baseDefinitionId?: string; + search?: string; + isFavorite?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateCustomReportDto { + ownerId: string; + baseDefinitionId?: string | null; + name: string; + description?: string | null; + customColumns?: Record[]; + customFilters?: Record[]; + customGrouping?: Record[]; + customSorting?: Record[]; + isFavorite?: boolean; +} + +export interface UpdateCustomReportDto extends Partial> {} + +export class CustomReportService { + constructor(private readonly customReportRepository: Repository) {} + + // ==================== CRUD Operations ==================== + + async findAll(params: CustomReportSearchParams): Promise<{ data: CustomReport[]; total: number }> { + const { + tenantId, + ownerId, + baseDefinitionId, + search, + isFavorite, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (ownerId) { + baseWhere.ownerId = ownerId; + } + + if (baseDefinitionId) { + baseWhere.baseDefinitionId = baseDefinitionId; + } + + if (isFavorite !== undefined) { + baseWhere.isFavorite = isFavorite; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.customReportRepository.findAndCount({ + where, + relations: ['baseDefinition'], + take: limit, + skip: offset, + order: { isFavorite: 'DESC', name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.customReportRepository.findOne({ + where: { id, tenantId }, + relations: ['baseDefinition'], + }); + } + + async findByOwner( + ownerId: string, + tenantId: string, + options?: { + baseDefinitionId?: string; + isFavorite?: boolean; + limit?: number; + } + ): Promise { + const where: FindOptionsWhere = { ownerId, tenantId }; + + if (options?.baseDefinitionId) { + where.baseDefinitionId = options.baseDefinitionId; + } + + if (options?.isFavorite !== undefined) { + where.isFavorite = options.isFavorite; + } + + return this.customReportRepository.find({ + where, + relations: ['baseDefinition'], + order: { isFavorite: 'DESC', name: 'ASC' }, + take: options?.limit, + }); + } + + async create(tenantId: string, dto: CreateCustomReportDto): Promise { + const customReport = this.customReportRepository.create({ + ...dto, + tenantId, + }); + + return this.customReportRepository.save(customReport); + } + + async update( + id: string, + tenantId: string, + ownerId: string, + dto: UpdateCustomReportDto + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + Object.assign(customReport, dto); + + return this.customReportRepository.save(customReport); + } + + async delete(id: string, tenantId: string, ownerId: string): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return false; + + await this.customReportRepository.delete(id); + return true; + } + + // ==================== Favorite Operations ==================== + + async toggleFavorite( + id: string, + tenantId: string, + ownerId: string + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + customReport.isFavorite = !customReport.isFavorite; + + return this.customReportRepository.save(customReport); + } + + async getFavorites(ownerId: string, tenantId: string): Promise { + return this.customReportRepository.find({ + where: { ownerId, tenantId, isFavorite: true }, + relations: ['baseDefinition'], + order: { name: 'ASC' }, + }); + } + + // ==================== Clone Operations ==================== + + async clone( + id: string, + tenantId: string, + ownerId: string, + newName: string + ): Promise { + const source = await this.findOne(id, tenantId); + if (!source) { + throw new Error('Source custom report not found'); + } + + const cloned = this.customReportRepository.create({ + tenantId, + ownerId, + baseDefinitionId: source.baseDefinitionId, + name: newName, + description: source.description, + customColumns: [...source.customColumns], + customFilters: [...source.customFilters], + customGrouping: [...source.customGrouping], + customSorting: [...source.customSorting], + isFavorite: false, + }); + + return this.customReportRepository.save(cloned); + } + + // ==================== Column Configuration ==================== + + async updateColumns( + id: string, + tenantId: string, + ownerId: string, + columns: Record[] + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + customReport.customColumns = columns; + + return this.customReportRepository.save(customReport); + } + + async updateFilters( + id: string, + tenantId: string, + ownerId: string, + filters: Record[] + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + customReport.customFilters = filters; + + return this.customReportRepository.save(customReport); + } + + async updateGrouping( + id: string, + tenantId: string, + ownerId: string, + grouping: Record[] + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + customReport.customGrouping = grouping; + + return this.customReportRepository.save(customReport); + } + + async updateSorting( + id: string, + tenantId: string, + ownerId: string, + sorting: Record[] + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) return null; + + customReport.customSorting = sorting; + + return this.customReportRepository.save(customReport); + } + + // ==================== History Operations ==================== + + async getHistory( + tenantId: string, + ownerId: string, + limit = 20 + ): Promise { + return this.customReportRepository.find({ + where: { tenantId, ownerId }, + relations: ['baseDefinition'], + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + // ==================== Export Operations ==================== + + async export( + id: string, + tenantId: string, + ownerId: string, + format: 'pdf' | 'excel' | 'csv' + ): Promise<{ filePath: string; mimeType: string }> { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) { + throw new Error('Custom report not found'); + } + + // This would integrate with actual export logic + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/custom_report_${id}_${Date.now()}.${format}`, + mimeType: mimeTypes[format], + }; + } + + // ==================== Generate Report ==================== + + async generate( + id: string, + tenantId: string, + ownerId: string, + parameters?: Record + ): Promise<{ data: any[]; rowCount: number }> { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + relations: ['baseDefinition'], + }); + + if (!customReport) { + throw new Error('Custom report not found'); + } + + // This would integrate with actual report generation logic + // combining base definition query with custom columns, filters, grouping, sorting + return { + data: [], + rowCount: 0, + }; + } + + async schedule( + id: string, + tenantId: string, + ownerId: string, + cronExpression: string + ): Promise { + const customReport = await this.customReportRepository.findOne({ + where: { id, tenantId, ownerId }, + }); + + if (!customReport) { + throw new Error('Custom report not found'); + } + + // This would integrate with the report schedule service + // to create a schedule for this custom report + // For now, this is a placeholder + } +} diff --git a/src/modules/reports/services/dashboard.service.ts b/src/modules/reports/services/dashboard.service.ts new file mode 100644 index 0000000..f129fca --- /dev/null +++ b/src/modules/reports/services/dashboard.service.ts @@ -0,0 +1,529 @@ +/** + * Dashboard Service + * Manages dashboards, widgets, and widget queries + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, ILike, IsNull } from 'typeorm'; +import { + Dashboard, + DashboardType, + DashboardVisibility, +} from '../entities/dashboard.entity'; +import { DashboardWidget, WidgetType, DataSourceType } from '../entities/dashboard-widget.entity'; +import { WidgetQuery } from '../entities/widget-query.entity'; + +export interface DashboardSearchParams { + tenantId: string; + search?: string; + dashboardType?: DashboardType; + visibility?: DashboardVisibility; + ownerId?: string; + fraccionamientoId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateDashboardDto { + code: string; + name: string; + description?: string | null; + dashboardType?: DashboardType; + visibility?: DashboardVisibility; + ownerId?: string | null; + fraccionamientoId?: string | null; + layout?: Record | null; + theme?: Record | null; + refreshInterval?: number; + defaultDateRange?: string; + defaultFilters?: Record | null; + allowedRoles?: string[] | null; + isDefault?: boolean; + isActive?: boolean; + isSystem?: boolean; + sortOrder?: number; +} + +export interface UpdateDashboardDto extends Partial {} + +export interface CreateWidgetDto { + dashboardId: string; + title: string; + subtitle?: string | null; + widgetType: WidgetType; + dataSourceType?: DataSourceType; + dataSource?: Record | null; + config?: Record | null; + chartOptions?: Record | null; + thresholds?: Record | null; + gridX?: number; + gridY?: number; + gridWidth?: number; + gridHeight?: number; + minWidth?: number; + minHeight?: number; + refreshInterval?: number | null; + cacheDuration?: number; + drillDownConfig?: Record | null; + clickAction?: Record | null; + isActive?: boolean; + sortOrder?: number; +} + +export interface UpdateWidgetDto extends Partial> {} + +export interface CreateWidgetQueryDto { + widgetId: string; + name: string; + queryText?: string | null; + queryFunction?: string | null; + parameters?: Record; + resultMapping?: Record; + cacheTtlSeconds?: number | null; +} + +export interface UpdateWidgetQueryDto extends Partial> {} + +export class DashboardService { + constructor( + private readonly dashboardRepository: Repository, + private readonly widgetRepository: Repository, + private readonly widgetQueryRepository: Repository + ) {} + + // ==================== Dashboard CRUD ==================== + + async findAll(params: DashboardSearchParams): Promise<{ data: Dashboard[]; total: number }> { + const { + tenantId, + search, + dashboardType, + visibility, + ownerId, + fraccionamientoId, + isActive, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { + tenantId, + deletedAt: IsNull(), + }; + + if (dashboardType) { + baseWhere.dashboardType = dashboardType; + } + + if (visibility) { + baseWhere.visibility = visibility; + } + + if (ownerId) { + baseWhere.ownerId = ownerId; + } + + if (fraccionamientoId) { + baseWhere.fraccionamientoId = fraccionamientoId; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.dashboardRepository.findAndCount({ + where, + relations: ['widgets'], + take: limit, + skip: offset, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.dashboardRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + relations: ['widgets'], + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.dashboardRepository.findOne({ + where: { code, tenantId, deletedAt: IsNull() }, + relations: ['widgets'], + }); + } + + async create( + tenantId: string, + dto: CreateDashboardDto, + createdById?: string + ): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A dashboard with this code already exists'); + } + + const dashboard = this.dashboardRepository.create({ + ...dto, + tenantId, + createdById, + }); + + return this.dashboardRepository.save(dashboard); + } + + async update( + id: string, + tenantId: string, + dto: UpdateDashboardDto, + updatedById?: string + ): Promise { + const dashboard = await this.findOne(id, tenantId); + if (!dashboard) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== dashboard.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A dashboard with this code already exists'); + } + } + + // Cannot modify system dashboards + if (dashboard.isSystem && dto.isSystem === false) { + throw new Error('Cannot modify system dashboard flag'); + } + + Object.assign(dashboard, { + ...dto, + updatedById, + }); + + return this.dashboardRepository.save(dashboard); + } + + async delete(id: string, tenantId: string, deletedById?: string): Promise { + const dashboard = await this.findOne(id, tenantId); + if (!dashboard) return false; + + // Cannot delete system dashboards + if (dashboard.isSystem) { + throw new Error('Cannot delete system dashboards'); + } + + dashboard.deletedAt = new Date(); + dashboard.deletedById = deletedById || null; + await this.dashboardRepository.save(dashboard); + + return true; + } + + // ==================== Dashboard Operations ==================== + + async getDefaultDashboard( + tenantId: string, + dashboardType?: DashboardType + ): Promise { + const where: FindOptionsWhere = { + tenantId, + isDefault: true, + isActive: true, + deletedAt: IsNull(), + }; + + if (dashboardType) { + where.dashboardType = dashboardType; + } + + return this.dashboardRepository.findOne({ + where, + relations: ['widgets'], + }); + } + + async setAsDefault( + id: string, + tenantId: string, + updatedById?: string + ): Promise { + const dashboard = await this.findOne(id, tenantId); + if (!dashboard) return null; + + // Remove default from other dashboards of same type + await this.dashboardRepository.update( + { tenantId, dashboardType: dashboard.dashboardType, isDefault: true }, + { isDefault: false } + ); + + dashboard.isDefault = true; + dashboard.updatedById = updatedById || null; + + return this.dashboardRepository.save(dashboard); + } + + async updateViewCount(id: string, tenantId: string): Promise { + await this.dashboardRepository.update( + { id, tenantId }, + { + viewCount: () => 'view_count + 1', + lastViewedAt: new Date(), + } + ); + } + + async clone( + id: string, + tenantId: string, + newCode: string, + newName: string, + createdById?: string + ): Promise { + const source = await this.findOne(id, tenantId); + if (!source) { + throw new Error('Source dashboard not found'); + } + + // Check new code doesn't exist + const existingCode = await this.findByCode(newCode, tenantId); + if (existingCode) { + throw new Error('A dashboard with this code already exists'); + } + + // Create cloned dashboard + const cloned = this.dashboardRepository.create({ + tenantId, + code: newCode, + name: newName, + description: source.description, + dashboardType: source.dashboardType, + visibility: 'private' as DashboardVisibility, + ownerId: createdById || null, + fraccionamientoId: source.fraccionamientoId, + layout: source.layout, + theme: source.theme, + refreshInterval: source.refreshInterval, + defaultDateRange: source.defaultDateRange, + defaultFilters: source.defaultFilters, + allowedRoles: null, + isDefault: false, + isActive: true, + isSystem: false, + sortOrder: 0, + createdById, + }); + + const savedDashboard = await this.dashboardRepository.save(cloned); + + // Clone widgets + if (source.widgets && source.widgets.length > 0) { + for (const widget of source.widgets) { + await this.createWidget(tenantId, { + dashboardId: savedDashboard.id, + title: widget.title, + subtitle: widget.subtitle, + widgetType: widget.widgetType, + dataSourceType: widget.dataSourceType, + dataSource: widget.dataSource, + config: widget.config, + chartOptions: widget.chartOptions, + thresholds: widget.thresholds, + gridX: widget.gridX, + gridY: widget.gridY, + gridWidth: widget.gridWidth, + gridHeight: widget.gridHeight, + minWidth: widget.minWidth, + minHeight: widget.minHeight, + refreshInterval: widget.refreshInterval, + cacheDuration: widget.cacheDuration, + drillDownConfig: widget.drillDownConfig, + clickAction: widget.clickAction, + isActive: widget.isActive, + sortOrder: widget.sortOrder, + }, createdById); + } + } + + return this.findOne(savedDashboard.id, tenantId) as Promise; + } + + async getUserDashboards( + tenantId: string, + userId: string, + roles?: string[] + ): Promise { + const queryBuilder = this.dashboardRepository + .createQueryBuilder('d') + .leftJoinAndSelect('d.widgets', 'w') + .where('d.tenant_id = :tenantId', { tenantId }) + .andWhere('d.is_active = true') + .andWhere('d.deleted_at IS NULL') + .andWhere( + '(d.visibility = :company OR d.owner_id = :userId OR (d.visibility IN (:...visibilities) AND (d.allowed_roles IS NULL OR d.allowed_roles && :roles)))', + { + company: 'company', + userId, + visibilities: ['team', 'department'], + roles: roles || [], + } + ) + .orderBy('d.sort_order', 'ASC') + .addOrderBy('d.name', 'ASC'); + + return queryBuilder.getMany(); + } + + // ==================== Widget CRUD ==================== + + async getWidgets(dashboardId: string, tenantId: string): Promise { + return this.widgetRepository.find({ + where: { dashboardId, tenantId, deletedAt: IsNull() }, + order: { sortOrder: 'ASC', gridY: 'ASC', gridX: 'ASC' }, + }); + } + + async getWidget(id: string, tenantId: string): Promise { + return this.widgetRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + }); + } + + async createWidget( + tenantId: string, + dto: CreateWidgetDto, + createdById?: string + ): Promise { + const widget = this.widgetRepository.create({ + ...dto, + tenantId, + createdById, + }); + + return this.widgetRepository.save(widget); + } + + async updateWidget( + id: string, + tenantId: string, + dto: UpdateWidgetDto, + updatedById?: string + ): Promise { + const widget = await this.getWidget(id, tenantId); + if (!widget) return null; + + Object.assign(widget, { + ...dto, + updatedById, + }); + + return this.widgetRepository.save(widget); + } + + async deleteWidget(id: string, tenantId: string): Promise { + const widget = await this.getWidget(id, tenantId); + if (!widget) return false; + + widget.deletedAt = new Date(); + await this.widgetRepository.save(widget); + + return true; + } + + async updateWidgetPositions( + tenantId: string, + positions: Array<{ id: string; gridX: number; gridY: number; gridWidth: number; gridHeight: number }> + ): Promise { + for (const pos of positions) { + await this.widgetRepository.update( + { id: pos.id, tenantId }, + { + gridX: pos.gridX, + gridY: pos.gridY, + gridWidth: pos.gridWidth, + gridHeight: pos.gridHeight, + } + ); + } + } + + // ==================== Widget Query CRUD ==================== + + async getWidgetQueries(widgetId: string): Promise { + return this.widgetQueryRepository.find({ + where: { widgetId }, + order: { name: 'ASC' }, + }); + } + + async getWidgetQuery(id: string): Promise { + return this.widgetQueryRepository.findOne({ where: { id } }); + } + + async createWidgetQuery(dto: CreateWidgetQueryDto): Promise { + const query = this.widgetQueryRepository.create(dto); + return this.widgetQueryRepository.save(query); + } + + async updateWidgetQuery( + id: string, + dto: UpdateWidgetQueryDto + ): Promise { + const query = await this.getWidgetQuery(id); + if (!query) return null; + + Object.assign(query, dto); + return this.widgetQueryRepository.save(query); + } + + async deleteWidgetQuery(id: string): Promise { + const result = await this.widgetQueryRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async updateQueryCache(id: string): Promise { + await this.widgetQueryRepository.update(id, { + lastCachedAt: new Date(), + }); + } + + // ==================== Export Operations ==================== + + async export( + id: string, + tenantId: string, + format: 'pdf' | 'excel' | 'csv' + ): Promise<{ filePath: string; mimeType: string }> { + const dashboard = await this.findOne(id, tenantId); + if (!dashboard) { + throw new Error('Dashboard not found'); + } + + // This would integrate with actual export logic + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/dashboard_${id}_${Date.now()}.${format}`, + mimeType: mimeTypes[format], + }; + } +} diff --git a/src/modules/reports/services/data-model.service.ts b/src/modules/reports/services/data-model.service.ts new file mode 100644 index 0000000..70f0922 --- /dev/null +++ b/src/modules/reports/services/data-model.service.ts @@ -0,0 +1,481 @@ +/** + * Data Model Service + * Manages data model entities, fields, and relationships for report builder + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { DataModelEntity } from '../entities/data-model-entity.entity'; +import { DataModelField } from '../entities/data-model-field.entity'; +import { DataModelRelationship, RelationshipType } from '../entities/data-model-relationship.entity'; + +export interface DataModelEntitySearchParams { + search?: string; + schemaName?: string; + isActive?: boolean; + isMultiTenant?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateDataModelEntityDto { + name: string; + displayName: string; + description?: string | null; + schemaName: string; + tableName: string; + primaryKeyColumn?: string; + tenantColumn?: string | null; + isMultiTenant?: boolean; + isActive?: boolean; +} + +export interface UpdateDataModelEntityDto extends Partial {} + +export interface CreateDataModelFieldDto { + entityId: string; + name: string; + displayName: string; + description?: string | null; + dataType: string; + isNullable?: boolean; + isFilterable?: boolean; + isSortable?: boolean; + isGroupable?: boolean; + isAggregatable?: boolean; + aggregationFunctions?: string[]; + formatPattern?: string | null; + displayFormat?: string | null; + isActive?: boolean; + sortOrder?: number; +} + +export interface UpdateDataModelFieldDto extends Partial> {} + +export interface CreateDataModelRelationshipDto { + sourceEntityId: string; + targetEntityId: string; + name: string; + relationshipType: RelationshipType; + sourceColumn: string; + targetColumn: string; + joinCondition?: string | null; + isActive?: boolean; +} + +export interface UpdateDataModelRelationshipDto extends Partial> {} + +export class DataModelService { + constructor( + private readonly entityRepository: Repository, + private readonly fieldRepository: Repository, + private readonly relationshipRepository: Repository + ) {} + + // ==================== Entity CRUD ==================== + + async findAllEntities(params: DataModelEntitySearchParams): Promise<{ data: DataModelEntity[]; total: number }> { + const { + search, + schemaName, + isActive, + isMultiTenant, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = {}; + + if (schemaName) { + baseWhere.schemaName = schemaName; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (isMultiTenant !== undefined) { + baseWhere.isMultiTenant = isMultiTenant; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, displayName: ILike(`%${search}%`) }, + { ...baseWhere, tableName: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.entityRepository.findAndCount({ + where, + relations: ['fields'], + take: limit, + skip: offset, + order: { schemaName: 'ASC', displayName: 'ASC' }, + }); + + return { data, total }; + } + + async findOneEntity(id: string): Promise { + return this.entityRepository.findOne({ + where: { id }, + relations: ['fields', 'sourceRelationships', 'targetRelationships'], + }); + } + + async findEntityByName(name: string): Promise { + return this.entityRepository.findOne({ + where: { name }, + relations: ['fields'], + }); + } + + async createEntity(dto: CreateDataModelEntityDto): Promise { + // Check for existing name + const existingName = await this.findEntityByName(dto.name); + if (existingName) { + throw new Error('An entity with this name already exists'); + } + + const entity = this.entityRepository.create(dto); + return this.entityRepository.save(entity); + } + + async updateEntity(id: string, dto: UpdateDataModelEntityDto): Promise { + const entity = await this.findOneEntity(id); + if (!entity) return null; + + // If changing name, check for duplicates + if (dto.name && dto.name !== entity.name) { + const existing = await this.findEntityByName(dto.name); + if (existing) { + throw new Error('An entity with this name already exists'); + } + } + + Object.assign(entity, dto); + return this.entityRepository.save(entity); + } + + async deleteEntity(id: string): Promise { + const entity = await this.findOneEntity(id); + if (!entity) return false; + + // This will cascade delete fields and relationships + await this.entityRepository.delete(id); + return true; + } + + async getEntitiesBySchema(schemaName: string): Promise { + return this.entityRepository.find({ + where: { schemaName, isActive: true }, + relations: ['fields'], + order: { displayName: 'ASC' }, + }); + } + + async getActiveEntities(): Promise { + return this.entityRepository.find({ + where: { isActive: true }, + relations: ['fields'], + order: { schemaName: 'ASC', displayName: 'ASC' }, + }); + } + + // ==================== Field CRUD ==================== + + async getFields(entityId: string): Promise { + return this.fieldRepository.find({ + where: { entityId }, + order: { sortOrder: 'ASC', displayName: 'ASC' }, + }); + } + + async getField(id: string): Promise { + return this.fieldRepository.findOne({ + where: { id }, + relations: ['entity'], + }); + } + + async getFieldByName(entityId: string, name: string): Promise { + return this.fieldRepository.findOne({ + where: { entityId, name }, + }); + } + + async createField(dto: CreateDataModelFieldDto): Promise { + // Check for existing field with same name in entity + const existingField = await this.getFieldByName(dto.entityId, dto.name); + if (existingField) { + throw new Error('A field with this name already exists in this entity'); + } + + const field = this.fieldRepository.create(dto); + return this.fieldRepository.save(field); + } + + async createFieldsBatch(entityId: string, fields: Omit[]): Promise { + const fieldEntities = fields.map((f, index) => + this.fieldRepository.create({ + ...f, + entityId, + sortOrder: f.sortOrder ?? index, + }) + ); + + return this.fieldRepository.save(fieldEntities); + } + + async updateField(id: string, dto: UpdateDataModelFieldDto): Promise { + const field = await this.getField(id); + if (!field) return null; + + // If changing name, check for duplicates + if (dto.name && dto.name !== field.name) { + const existing = await this.getFieldByName(field.entityId, dto.name); + if (existing) { + throw new Error('A field with this name already exists in this entity'); + } + } + + Object.assign(field, dto); + return this.fieldRepository.save(field); + } + + async deleteField(id: string): Promise { + const result = await this.fieldRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async getFilterableFields(entityId: string): Promise { + return this.fieldRepository.find({ + where: { entityId, isFilterable: true, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async getSortableFields(entityId: string): Promise { + return this.fieldRepository.find({ + where: { entityId, isSortable: true, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async getGroupableFields(entityId: string): Promise { + return this.fieldRepository.find({ + where: { entityId, isGroupable: true, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async getAggregatableFields(entityId: string): Promise { + return this.fieldRepository.find({ + where: { entityId, isAggregatable: true, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + // ==================== Relationship CRUD ==================== + + async getRelationships(entityId: string): Promise<{ + source: DataModelRelationship[]; + target: DataModelRelationship[]; + }> { + const [source, target] = await Promise.all([ + this.relationshipRepository.find({ + where: { sourceEntityId: entityId }, + relations: ['sourceEntity', 'targetEntity'], + }), + this.relationshipRepository.find({ + where: { targetEntityId: entityId }, + relations: ['sourceEntity', 'targetEntity'], + }), + ]); + + return { source, target }; + } + + async getRelationship(id: string): Promise { + return this.relationshipRepository.findOne({ + where: { id }, + relations: ['sourceEntity', 'targetEntity'], + }); + } + + async createRelationship(dto: CreateDataModelRelationshipDto): Promise { + // Check for existing relationship + const existing = await this.relationshipRepository.findOne({ + where: { + sourceEntityId: dto.sourceEntityId, + targetEntityId: dto.targetEntityId, + name: dto.name, + }, + }); + + if (existing) { + throw new Error('A relationship with this name already exists between these entities'); + } + + const relationship = this.relationshipRepository.create(dto); + return this.relationshipRepository.save(relationship); + } + + async updateRelationship( + id: string, + dto: UpdateDataModelRelationshipDto + ): Promise { + const relationship = await this.getRelationship(id); + if (!relationship) return null; + + Object.assign(relationship, dto); + return this.relationshipRepository.save(relationship); + } + + async deleteRelationship(id: string): Promise { + const result = await this.relationshipRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async getRelatedEntities(entityId: string): Promise { + const relationships = await this.getRelationships(entityId); + + const relatedIds = new Set([ + ...relationships.source.map((r) => r.targetEntityId), + ...relationships.target.map((r) => r.sourceEntityId), + ]); + + if (relatedIds.size === 0) return []; + + return this.entityRepository.findByIds([...relatedIds]); + } + + // ==================== Data Model Operations ==================== + + async getCompleteModel(): Promise<{ + entities: DataModelEntity[]; + relationships: DataModelRelationship[]; + }> { + const [entities, relationships] = await Promise.all([ + this.entityRepository.find({ + where: { isActive: true }, + relations: ['fields'], + order: { schemaName: 'ASC', displayName: 'ASC' }, + }), + this.relationshipRepository.find({ + where: { isActive: true }, + relations: ['sourceEntity', 'targetEntity'], + }), + ]); + + return { entities, relationships }; + } + + async syncFromDatabase(schemaName: string): Promise<{ + created: number; + updated: number; + }> { + // This would introspect the database schema and create/update + // DataModelEntity and DataModelField records + // For now, this is a placeholder + return { created: 0, updated: 0 }; + } + + async validateQuery( + entityIds: string[], + fields: string[], + filters?: Record[], + grouping?: string[], + sorting?: Record[] + ): Promise<{ + valid: boolean; + errors: string[]; + }> { + const errors: string[] = []; + + // Validate entities exist + for (const entityId of entityIds) { + const entity = await this.findOneEntity(entityId); + if (!entity) { + errors.push(`Entity ${entityId} not found`); + } + } + + // Additional validation would check: + // - Fields belong to selected entities + // - Relationships exist between entities + // - Filters are valid for field types + // - Grouping fields are groupable + // - Sorting fields are sortable + + return { + valid: errors.length === 0, + errors, + }; + } + + async buildQuery( + entityIds: string[], + fields: Array<{ entityId: string; fieldName: string; alias?: string }>, + joins?: Array<{ relationshipId: string; joinType: 'inner' | 'left' }>, + filters?: Record[], + grouping?: string[], + sorting?: Array<{ field: string; direction: 'ASC' | 'DESC' }> + ): Promise { + // This would build a SQL query based on the data model + // For now, this is a placeholder + return ''; + } + + // ==================== Export Operations ==================== + + async export( + format: 'pdf' | 'excel' | 'csv' + ): Promise<{ filePath: string; mimeType: string }> { + // This would export the data model documentation + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/data_model_${Date.now()}.${format}`, + mimeType: mimeTypes[format], + }; + } + + async generate( + entityIds: string[], + params?: Record + ): Promise<{ data: any[]; rowCount: number }> { + // This would generate a report based on the data model + // For now, this is a placeholder + return { + data: [], + rowCount: 0, + }; + } + + async schedule( + entityIds: string[], + cronExpression: string + ): Promise { + // This would schedule a data model-based report + // For now, this is a placeholder + } + + async getHistory( + limit = 20 + ): Promise { + return this.entityRepository.find({ + order: { updatedAt: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..59067e7 --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,73 @@ +/** + * Reports Module - Service Exports + * MAI-006: Reportes y Analytics + */ + +// Report Definition Service +export { + ReportDefinitionService, + ReportSearchParams, + CreateReportDto, + UpdateReportDto, + GenerateReportParams, + ExportFormat, +} from './report-definition.service'; + +// Report Execution Service +export { + ReportExecutionService, + ExecutionSearchParams, + CreateExecutionDto, + ExecuteReportParams, + GenerateResult, +} from './report-execution.service'; + +// Report Schedule Service +export { + ReportScheduleService, + ScheduleSearchParams, + CreateScheduleDto, + UpdateScheduleDto, + CreateRecipientDto, +} from './report-schedule.service'; + +// Dashboard Service +export { + DashboardService, + DashboardSearchParams, + CreateDashboardDto, + UpdateDashboardDto, + CreateWidgetDto, + UpdateWidgetDto, + CreateWidgetQueryDto, + UpdateWidgetQueryDto, +} from './dashboard.service'; + +// KPI Snapshot Service +export { + KpiSnapshotService, + KpiSearchParams, + CreateKpiSnapshotDto, + KpiTrend, + KpiSummary, +} from './kpi-snapshot.service'; + +// Custom Report Service +export { + CustomReportService, + CustomReportSearchParams, + CreateCustomReportDto, + UpdateCustomReportDto, +} from './custom-report.service'; + +// Data Model Service +export { + DataModelService, + DataModelEntitySearchParams, + CreateDataModelEntityDto, + UpdateDataModelEntityDto, + CreateDataModelFieldDto, + UpdateDataModelFieldDto, + CreateDataModelRelationshipDto, + UpdateDataModelRelationshipDto, +} from './data-model.service'; diff --git a/src/modules/reports/services/kpi-snapshot.service.ts b/src/modules/reports/services/kpi-snapshot.service.ts new file mode 100644 index 0000000..7df86b7 --- /dev/null +++ b/src/modules/reports/services/kpi-snapshot.service.ts @@ -0,0 +1,536 @@ +/** + * KPI Snapshot Service + * Manages KPI snapshots for historical tracking and analysis + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + KpiSnapshot, + KpiCategory, + KpiPeriodType, + TrendDirection, +} from '../entities/kpi-snapshot.entity'; + +export interface KpiSearchParams { + tenantId: string; + kpiCode?: string; + category?: KpiCategory; + periodType?: KpiPeriodType; + fraccionamientoId?: string; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export interface CreateKpiSnapshotDto { + kpiCode: string; + kpiName: string; + category: KpiCategory; + snapshotDate: Date; + periodType?: KpiPeriodType; + periodStart?: Date | null; + periodEnd?: Date | null; + fraccionamientoId?: string | null; + value: number; + previousValue?: number | null; + targetValue?: number | null; + minValue?: number | null; + maxValue?: number | null; + unit?: string | null; + breakdown?: Record | null; + metadata?: Record | null; +} + +export interface KpiTrend { + kpiCode: string; + kpiName: string; + category: KpiCategory; + currentValue: number; + previousValue: number | null; + changePercentage: number | null; + trendDirection: TrendDirection; + isOnTarget: boolean | null; + statusColor: string | null; + snapshots: KpiSnapshot[]; +} + +export interface KpiSummary { + category: KpiCategory; + totalKpis: number; + onTarget: number; + belowTarget: number; + aboveTarget: number; + averagePerformance: number; +} + +export class KpiSnapshotService { + constructor(private readonly kpiSnapshotRepository: Repository) {} + + // ==================== CRUD Operations ==================== + + async findAll(params: KpiSearchParams): Promise<{ data: KpiSnapshot[]; total: number }> { + const { + tenantId, + kpiCode, + category, + periodType, + fraccionamientoId, + dateFrom, + dateTo, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (kpiCode) { + where.kpiCode = kpiCode; + } + + if (category) { + where.category = category; + } + + if (periodType) { + where.periodType = periodType; + } + + if (fraccionamientoId) { + where.fraccionamientoId = fraccionamientoId; + } + + if (dateFrom && dateTo) { + where.snapshotDate = Between(dateFrom, dateTo); + } else if (dateFrom) { + where.snapshotDate = MoreThanOrEqual(dateFrom); + } else if (dateTo) { + where.snapshotDate = LessThanOrEqual(dateTo); + } + + const [data, total] = await this.kpiSnapshotRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { snapshotDate: 'DESC', kpiCode: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.kpiSnapshotRepository.findOne({ + where: { id, tenantId }, + }); + } + + async findByKpiCode( + kpiCode: string, + tenantId: string, + options?: { + limit?: number; + dateFrom?: Date; + dateTo?: Date; + } + ): Promise { + const where: FindOptionsWhere = { kpiCode, tenantId }; + + if (options?.dateFrom && options?.dateTo) { + where.snapshotDate = Between(options.dateFrom, options.dateTo); + } else if (options?.dateFrom) { + where.snapshotDate = MoreThanOrEqual(options.dateFrom); + } else if (options?.dateTo) { + where.snapshotDate = LessThanOrEqual(options.dateTo); + } + + return this.kpiSnapshotRepository.find({ + where, + order: { snapshotDate: 'DESC' }, + take: options?.limit || 30, + }); + } + + async create(tenantId: string, dto: CreateKpiSnapshotDto): Promise { + // Calculate derived fields + const changePercentage = dto.previousValue + ? ((dto.value - dto.previousValue) / Math.abs(dto.previousValue)) * 100 + : null; + + const trendDirection = this.calculateTrendDirection(dto.value, dto.previousValue || null); + const isOnTarget = this.calculateIsOnTarget(dto.value, dto.targetValue || null); + const statusColor = this.calculateStatusColor(dto.value, dto.targetValue, dto.minValue, dto.maxValue); + + const snapshot = this.kpiSnapshotRepository.create({ + ...dto, + tenantId, + changePercentage, + trendDirection, + isOnTarget, + statusColor, + calculatedAt: new Date(), + }); + + return this.kpiSnapshotRepository.save(snapshot); + } + + async createBatch( + tenantId: string, + snapshots: CreateKpiSnapshotDto[] + ): Promise { + const kpiSnapshots = snapshots.map((dto) => { + const changePercentage = dto.previousValue + ? ((dto.value - dto.previousValue) / Math.abs(dto.previousValue)) * 100 + : null; + + const trendDirection = this.calculateTrendDirection(dto.value, dto.previousValue || null); + const isOnTarget = this.calculateIsOnTarget(dto.value, dto.targetValue || null); + const statusColor = this.calculateStatusColor(dto.value, dto.targetValue, dto.minValue, dto.maxValue); + + return this.kpiSnapshotRepository.create({ + ...dto, + tenantId, + changePercentage, + trendDirection, + isOnTarget, + statusColor, + calculatedAt: new Date(), + }); + }); + + return this.kpiSnapshotRepository.save(kpiSnapshots); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.kpiSnapshotRepository.delete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + // ==================== KPI Analysis ==================== + + async getLatestSnapshot( + kpiCode: string, + tenantId: string, + fraccionamientoId?: string + ): Promise { + const where: FindOptionsWhere = { kpiCode, tenantId }; + + if (fraccionamientoId) { + where.fraccionamientoId = fraccionamientoId; + } + + return this.kpiSnapshotRepository.findOne({ + where, + order: { snapshotDate: 'DESC' }, + }); + } + + async getLatestSnapshots( + tenantId: string, + kpiCodes?: string[], + fraccionamientoId?: string + ): Promise { + const queryBuilder = this.kpiSnapshotRepository + .createQueryBuilder('k') + .where('k.tenant_id = :tenantId', { tenantId }) + .andWhere( + `k.snapshot_date = ( + SELECT MAX(k2.snapshot_date) + FROM reports.kpi_snapshots k2 + WHERE k2.kpi_code = k.kpi_code + AND k2.tenant_id = k.tenant_id + ${fraccionamientoId ? 'AND k2.fraccionamiento_id = :fraccionamientoId' : ''} + )`, + fraccionamientoId ? { fraccionamientoId } : {} + ); + + if (kpiCodes && kpiCodes.length > 0) { + queryBuilder.andWhere('k.kpi_code IN (:...kpiCodes)', { kpiCodes }); + } + + if (fraccionamientoId) { + queryBuilder.andWhere('k.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + return queryBuilder.orderBy('k.category', 'ASC').addOrderBy('k.kpi_code', 'ASC').getMany(); + } + + async getKpiTrend( + kpiCode: string, + tenantId: string, + periods = 12, + periodType: KpiPeriodType = 'monthly', + fraccionamientoId?: string + ): Promise { + const where: FindOptionsWhere = { + kpiCode, + tenantId, + periodType, + }; + + if (fraccionamientoId) { + where.fraccionamientoId = fraccionamientoId; + } + + const snapshots = await this.kpiSnapshotRepository.find({ + where, + order: { snapshotDate: 'DESC' }, + take: periods, + }); + + if (snapshots.length === 0) return null; + + const latest = snapshots[0]; + const previous = snapshots.length > 1 ? snapshots[1] : null; + + return { + kpiCode: latest.kpiCode, + kpiName: latest.kpiName, + category: latest.category, + currentValue: Number(latest.value), + previousValue: previous ? Number(previous.value) : null, + changePercentage: latest.changePercentage ? Number(latest.changePercentage) : null, + trendDirection: latest.trendDirection || 'stable', + isOnTarget: latest.isOnTarget, + statusColor: latest.statusColor, + snapshots: snapshots.reverse(), + }; + } + + async getKpiSummaryByCategory( + tenantId: string, + fraccionamientoId?: string + ): Promise { + const latestSnapshots = await this.getLatestSnapshots(tenantId, undefined, fraccionamientoId); + + const summaryMap = new Map(); + + for (const snapshot of latestSnapshots) { + const existing = summaryMap.get(snapshot.category) || { + category: snapshot.category, + totalKpis: 0, + onTarget: 0, + belowTarget: 0, + aboveTarget: 0, + averagePerformance: 0, + }; + + existing.totalKpis++; + + if (snapshot.isOnTarget === true) { + existing.onTarget++; + } else if (snapshot.isOnTarget === false) { + // Determine if below or above target + if (snapshot.targetValue !== null) { + if (Number(snapshot.value) < Number(snapshot.targetValue)) { + existing.belowTarget++; + } else { + existing.aboveTarget++; + } + } + } + + summaryMap.set(snapshot.category, existing); + } + + // Calculate average performance for each category + for (const [category, summary] of summaryMap) { + summary.averagePerformance = summary.totalKpis > 0 + ? (summary.onTarget / summary.totalKpis) * 100 + : 0; + } + + return Array.from(summaryMap.values()); + } + + async compareKpisByPeriod( + tenantId: string, + kpiCode: string, + period1Start: Date, + period1End: Date, + period2Start: Date, + period2End: Date, + fraccionamientoId?: string + ): Promise<{ + period1: KpiSnapshot[]; + period2: KpiSnapshot[]; + comparison: { + period1Average: number; + period2Average: number; + changePercentage: number; + trendDirection: TrendDirection; + }; + }> { + const baseFindOptions = { + kpiCode, + tenantId, + ...(fraccionamientoId && { fraccionamientoId }), + }; + + const [period1, period2] = await Promise.all([ + this.kpiSnapshotRepository.find({ + where: { + ...baseFindOptions, + snapshotDate: Between(period1Start, period1End), + }, + order: { snapshotDate: 'ASC' }, + }), + this.kpiSnapshotRepository.find({ + where: { + ...baseFindOptions, + snapshotDate: Between(period2Start, period2End), + }, + order: { snapshotDate: 'ASC' }, + }), + ]); + + const period1Average = period1.length > 0 + ? period1.reduce((sum, s) => sum + Number(s.value), 0) / period1.length + : 0; + + const period2Average = period2.length > 0 + ? period2.reduce((sum, s) => sum + Number(s.value), 0) / period2.length + : 0; + + const changePercentage = period1Average !== 0 + ? ((period2Average - period1Average) / Math.abs(period1Average)) * 100 + : 0; + + return { + period1, + period2, + comparison: { + period1Average, + period2Average, + changePercentage, + trendDirection: this.calculateTrendDirection(period2Average, period1Average), + }, + }; + } + + // ==================== History Operations ==================== + + async getHistory( + tenantId: string, + options?: { + kpiCode?: string; + category?: KpiCategory; + fraccionamientoId?: string; + limit?: number; + daysBack?: number; + } + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (options?.kpiCode) { + where.kpiCode = options.kpiCode; + } + + if (options?.category) { + where.category = options.category; + } + + if (options?.fraccionamientoId) { + where.fraccionamientoId = options.fraccionamientoId; + } + + if (options?.daysBack) { + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - options.daysBack); + where.snapshotDate = MoreThanOrEqual(dateFrom); + } + + return this.kpiSnapshotRepository.find({ + where, + order: { snapshotDate: 'DESC' }, + take: options?.limit || 100, + }); + } + + async cleanupOldSnapshots( + tenantId: string, + daysToKeep = 365 + ): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.kpiSnapshotRepository.delete({ + tenantId, + snapshotDate: LessThanOrEqual(cutoffDate), + }); + + return result.affected || 0; + } + + // ==================== Export Operations ==================== + + async export( + tenantId: string, + format: 'pdf' | 'excel' | 'csv', + options?: { + kpiCodes?: string[]; + category?: KpiCategory; + dateFrom?: Date; + dateTo?: Date; + } + ): Promise<{ filePath: string; mimeType: string }> { + // This would integrate with actual export logic + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/kpi_snapshots_${Date.now()}.${format}`, + mimeType: mimeTypes[format], + }; + } + + // ==================== Private Methods ==================== + + private calculateTrendDirection( + currentValue: number, + previousValue: number | null + ): TrendDirection { + if (previousValue === null) return 'stable'; + + const threshold = 0.01; // 1% threshold for stability + const change = (currentValue - previousValue) / Math.abs(previousValue); + + if (change > threshold) return 'up'; + if (change < -threshold) return 'down'; + return 'stable'; + } + + private calculateIsOnTarget( + value: number, + targetValue: number | null + ): boolean | null { + if (targetValue === null) return null; + + // Consider a 5% tolerance + const tolerance = 0.05; + const lowerBound = targetValue * (1 - tolerance); + + return value >= lowerBound; + } + + private calculateStatusColor( + value: number, + targetValue?: number | null, + minValue?: number | null, + maxValue?: number | null + ): string | null { + if (targetValue === null || targetValue === undefined) return null; + + // Calculate thresholds + const greenThreshold = targetValue * 0.95; // Within 5% of target + const yellowThreshold = targetValue * 0.80; // Within 20% of target + + if (value >= greenThreshold) return 'green'; + if (value >= yellowThreshold) return 'yellow'; + return 'red'; + } +} diff --git a/src/modules/reports/services/report-definition.service.ts b/src/modules/reports/services/report-definition.service.ts new file mode 100644 index 0000000..75f4178 --- /dev/null +++ b/src/modules/reports/services/report-definition.service.ts @@ -0,0 +1,403 @@ +/** + * Report Definition Service + * Manages report definitions CRUD and related operations + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, ILike, IsNull } from 'typeorm'; +import { + Report, + ReportType, + ReportFormat, + ReportFrequency, +} from '../entities/report.entity'; + +export interface ReportSearchParams { + tenantId: string; + search?: string; + reportType?: ReportType; + isActive?: boolean; + isScheduled?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateReportDto { + code: string; + name: string; + description?: string | null; + reportType: ReportType; + defaultFormat?: ReportFormat; + availableFormats?: ReportFormat[]; + query?: Record | null; + parameters?: Record | null; + columns?: Record[] | null; + grouping?: Record | null; + sorting?: Record[] | null; + filters?: Record | null; + templatePath?: string | null; + isScheduled?: boolean; + frequency?: ReportFrequency | null; + scheduleConfig?: Record | null; + distributionList?: string[] | null; + isActive?: boolean; + isSystem?: boolean; +} + +export interface UpdateReportDto extends Partial {} + +export interface GenerateReportParams { + reportId: string; + tenantId: string; + format?: ReportFormat; + parameters?: Record; + filters?: Record; + userId?: string; +} + +export interface ExportFormat { + format: 'pdf' | 'excel' | 'csv'; + options?: Record; +} + +export class ReportDefinitionService { + constructor(private readonly reportRepository: Repository) {} + + // ==================== CRUD Operations ==================== + + async findAll(params: ReportSearchParams): Promise<{ data: Report[]; total: number }> { + const { + tenantId, + search, + reportType, + isActive, + isScheduled, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { + tenantId, + deletedAt: IsNull(), + }; + + if (reportType) { + baseWhere.reportType = reportType; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (isScheduled !== undefined) { + baseWhere.isScheduled = isScheduled; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.reportRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.reportRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() }, + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.reportRepository.findOne({ + where: { code, tenantId, deletedAt: IsNull() }, + }); + } + + async findByType( + tenantId: string, + reportType: ReportType + ): Promise { + return this.reportRepository.find({ + where: { tenantId, reportType, isActive: true, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + } + + async create( + tenantId: string, + dto: CreateReportDto, + createdById?: string + ): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A report with this code already exists'); + } + + const report = this.reportRepository.create({ + ...dto, + tenantId, + createdById, + }); + + return this.reportRepository.save(report); + } + + async update( + id: string, + tenantId: string, + dto: UpdateReportDto, + updatedById?: string + ): Promise { + const report = await this.findOne(id, tenantId); + if (!report) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== report.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A report with this code already exists'); + } + } + + // Cannot modify system reports + if (report.isSystem && dto.isSystem === false) { + throw new Error('Cannot modify system report flag'); + } + + Object.assign(report, { + ...dto, + updatedById, + }); + + return this.reportRepository.save(report); + } + + async delete(id: string, tenantId: string, deletedById?: string): Promise { + const report = await this.findOne(id, tenantId); + if (!report) return false; + + // Cannot delete system reports + if (report.isSystem) { + throw new Error('Cannot delete system reports'); + } + + report.deletedAt = new Date(); + report.deletedById = deletedById || null; + await this.reportRepository.save(report); + + return true; + } + + // ==================== Report Operations ==================== + + async getScheduledReports(tenantId: string): Promise { + return this.reportRepository.find({ + where: { + tenantId, + isScheduled: true, + isActive: true, + deletedAt: IsNull(), + }, + order: { name: 'ASC' }, + }); + } + + async getSystemReports(tenantId: string): Promise { + return this.reportRepository.find({ + where: { + tenantId, + isSystem: true, + isActive: true, + deletedAt: IsNull(), + }, + order: { name: 'ASC' }, + }); + } + + async updateExecutionStats( + id: string, + tenantId: string + ): Promise { + await this.reportRepository.update( + { id, tenantId }, + { + executionCount: () => 'execution_count + 1', + lastExecutedAt: new Date(), + } + ); + } + + async toggleActive( + id: string, + tenantId: string, + updatedById?: string + ): Promise { + const report = await this.findOne(id, tenantId); + if (!report) return null; + + report.isActive = !report.isActive; + report.updatedById = updatedById || null; + + return this.reportRepository.save(report); + } + + async clone( + id: string, + tenantId: string, + newCode: string, + newName: string, + createdById?: string + ): Promise { + const source = await this.findOne(id, tenantId); + if (!source) { + throw new Error('Source report not found'); + } + + // Check new code doesn't exist + const existingCode = await this.findByCode(newCode, tenantId); + if (existingCode) { + throw new Error('A report with this code already exists'); + } + + const cloned = this.reportRepository.create({ + tenantId, + code: newCode, + name: newName, + description: source.description, + reportType: source.reportType, + defaultFormat: source.defaultFormat, + availableFormats: source.availableFormats, + query: source.query, + parameters: source.parameters, + columns: source.columns, + grouping: source.grouping, + sorting: source.sorting, + filters: source.filters, + templatePath: source.templatePath, + isScheduled: false, + frequency: null, + scheduleConfig: null, + distributionList: null, + isActive: true, + isSystem: false, + createdById, + }); + + return this.reportRepository.save(cloned); + } + + // ==================== Export Methods ==================== + + async export( + id: string, + tenantId: string, + exportFormat: ExportFormat + ): Promise<{ filePath: string; mimeType: string }> { + const report = await this.findOne(id, tenantId); + if (!report) { + throw new Error('Report not found'); + } + + // Validate format is available + if (!report.availableFormats.includes(exportFormat.format)) { + throw new Error(`Format ${exportFormat.format} is not available for this report`); + } + + // This would integrate with actual export implementation + // For now, return placeholder + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/${report.code}_${Date.now()}.${exportFormat.format}`, + mimeType: mimeTypes[exportFormat.format], + }; + } + + // ==================== Schedule Methods ==================== + + async schedule( + id: string, + tenantId: string, + cronExpression: string, + options?: { + frequency?: ReportFrequency; + distributionList?: string[]; + scheduleConfig?: Record; + }, + updatedById?: string + ): Promise { + const report = await this.findOne(id, tenantId); + if (!report) return null; + + report.isScheduled = true; + report.frequency = options?.frequency || 'daily'; + report.scheduleConfig = { + ...(report.scheduleConfig || {}), + cronExpression, + ...(options?.scheduleConfig || {}), + }; + report.distributionList = options?.distributionList || report.distributionList; + report.updatedById = updatedById || null; + + return this.reportRepository.save(report); + } + + async unschedule( + id: string, + tenantId: string, + updatedById?: string + ): Promise { + const report = await this.findOne(id, tenantId); + if (!report) return null; + + report.isScheduled = false; + report.updatedById = updatedById || null; + + return this.reportRepository.save(report); + } + + // ==================== History Methods ==================== + + async getHistory( + tenantId: string, + options?: { + reportType?: ReportType; + limit?: number; + } + ): Promise { + const where: FindOptionsWhere = { + tenantId, + isActive: true, + deletedAt: IsNull(), + }; + + if (options?.reportType) { + where.reportType = options.reportType; + } + + return this.reportRepository.find({ + where, + order: { lastExecutedAt: 'DESC' }, + take: options?.limit || 20, + }); + } +} diff --git a/src/modules/reports/services/report-execution.service.ts b/src/modules/reports/services/report-execution.service.ts new file mode 100644 index 0000000..57aa200 --- /dev/null +++ b/src/modules/reports/services/report-execution.service.ts @@ -0,0 +1,412 @@ +/** + * Report Execution Service + * Manages report execution, tracking, and history + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { ReportExecution, ExecutionStatus } from '../entities/report-execution.entity'; +import { Report, ReportFormat } from '../entities/report.entity'; + +export interface ExecutionSearchParams { + tenantId: string; + reportId?: string; + status?: ExecutionStatus; + executedById?: string; + isScheduled?: boolean; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export interface CreateExecutionDto { + reportId: string; + format: ReportFormat; + parameters?: Record | null; + filters?: Record | null; + isScheduled?: boolean; +} + +export interface ExecuteReportParams { + reportId: string; + tenantId: string; + format?: ReportFormat; + parameters?: Record; + filters?: Record; + executedById?: string; + isScheduled?: boolean; +} + +export interface GenerateResult { + execution: ReportExecution; + data?: any[]; + filePath?: string; + fileUrl?: string; +} + +export class ReportExecutionService { + constructor( + private readonly executionRepository: Repository, + private readonly reportRepository: Repository + ) {} + + // ==================== CRUD Operations ==================== + + async findAll(params: ExecutionSearchParams): Promise<{ data: ReportExecution[]; total: number }> { + const { + tenantId, + reportId, + status, + executedById, + isScheduled, + dateFrom, + dateTo, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (reportId) { + where.reportId = reportId; + } + + if (status) { + where.status = status; + } + + if (executedById) { + where.executedById = executedById; + } + + if (isScheduled !== undefined) { + where.isScheduled = isScheduled; + } + + if (dateFrom && dateTo) { + where.executedAt = Between(dateFrom, dateTo); + } else if (dateFrom) { + where.executedAt = MoreThanOrEqual(dateFrom); + } else if (dateTo) { + where.executedAt = LessThanOrEqual(dateTo); + } + + const [data, total] = await this.executionRepository.findAndCount({ + where, + relations: ['report'], + take: limit, + skip: offset, + order: { executedAt: 'DESC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.executionRepository.findOne({ + where: { id, tenantId }, + relations: ['report'], + }); + } + + async findByReportId( + reportId: string, + tenantId: string, + limit = 20 + ): Promise { + return this.executionRepository.find({ + where: { reportId, tenantId }, + order: { executedAt: 'DESC' }, + take: limit, + }); + } + + // ==================== Execution Operations ==================== + + async generate(params: ExecuteReportParams): Promise { + const { + reportId, + tenantId, + format, + parameters, + filters, + executedById, + isScheduled = false, + } = params; + + // Get report definition + const report = await this.reportRepository.findOne({ + where: { id: reportId, tenantId }, + }); + + if (!report) { + throw new Error('Report not found'); + } + + if (!report.isActive) { + throw new Error('Report is not active'); + } + + const executionFormat = format || report.defaultFormat; + + // Create execution record + const execution = this.executionRepository.create({ + tenantId, + reportId, + status: 'pending' as ExecutionStatus, + format: executionFormat, + parameters: parameters || null, + filters: filters || null, + isScheduled, + executedById: executedById || null, + executedAt: new Date(), + }); + + await this.executionRepository.save(execution); + + try { + // Update status to running + execution.status = 'running'; + execution.startedAt = new Date(); + await this.executionRepository.save(execution); + + // Execute the report (this would integrate with actual report generation logic) + const result = await this.executeReportQuery(report, parameters, filters); + + // Update execution record + execution.status = 'completed'; + execution.completedAt = new Date(); + execution.durationMs = Date.now() - execution.startedAt.getTime(); + execution.rowCount = result.rowCount; + execution.filePath = result.filePath || null; + execution.fileSize = result.fileSize || null; + execution.fileUrl = result.fileUrl || null; + execution.urlExpiresAt = result.urlExpiresAt || null; + + await this.executionRepository.save(execution); + + // Update report execution stats + await this.reportRepository.update( + { id: reportId }, + { + executionCount: () => 'execution_count + 1', + lastExecutedAt: new Date(), + } + ); + + return { + execution, + data: result.data, + filePath: result.filePath, + fileUrl: result.fileUrl, + }; + } catch (error) { + // Update execution with error + execution.status = 'failed'; + execution.completedAt = new Date(); + execution.durationMs = execution.startedAt + ? Date.now() - execution.startedAt.getTime() + : null; + execution.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + execution.errorStack = error instanceof Error ? error.stack || null : null; + + await this.executionRepository.save(execution); + + throw error; + } + } + + async cancel(id: string, tenantId: string): Promise { + const execution = await this.findOne(id, tenantId); + if (!execution) return null; + + if (execution.status !== 'pending' && execution.status !== 'running') { + throw new Error('Can only cancel pending or running executions'); + } + + execution.status = 'cancelled'; + execution.completedAt = new Date(); + + return this.executionRepository.save(execution); + } + + async retry( + id: string, + tenantId: string, + executedById?: string + ): Promise { + const originalExecution = await this.findOne(id, tenantId); + if (!originalExecution) { + throw new Error('Execution not found'); + } + + if (originalExecution.status !== 'failed') { + throw new Error('Can only retry failed executions'); + } + + return this.generate({ + reportId: originalExecution.reportId, + tenantId, + format: originalExecution.format, + parameters: originalExecution.parameters || undefined, + filters: originalExecution.filters || undefined, + executedById, + isScheduled: originalExecution.isScheduled, + }); + } + + // ==================== Export Operations ==================== + + async export( + id: string, + tenantId: string, + format: 'pdf' | 'excel' | 'csv' + ): Promise<{ filePath: string; mimeType: string }> { + const execution = await this.findOne(id, tenantId); + if (!execution) { + throw new Error('Execution not found'); + } + + if (execution.status !== 'completed') { + throw new Error('Can only export completed executions'); + } + + // This would integrate with actual export logic + const mimeTypes: Record = { + pdf: 'application/pdf', + excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + }; + + return { + filePath: `/exports/execution_${id}_${Date.now()}.${format}`, + mimeType: mimeTypes[format], + }; + } + + // ==================== History Operations ==================== + + async getHistory( + tenantId: string, + options?: { + reportId?: string; + limit?: number; + daysBack?: number; + } + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (options?.reportId) { + where.reportId = options.reportId; + } + + if (options?.daysBack) { + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - options.daysBack); + where.executedAt = MoreThanOrEqual(dateFrom); + } + + return this.executionRepository.find({ + where, + relations: ['report'], + order: { executedAt: 'DESC' }, + take: options?.limit || 50, + }); + } + + async getStatistics( + tenantId: string, + reportId?: string, + dateFrom?: Date, + dateTo?: Date + ): Promise<{ + total: number; + completed: number; + failed: number; + cancelled: number; + averageDuration: number; + totalRows: number; + }> { + const queryBuilder = this.executionRepository + .createQueryBuilder('e') + .where('e.tenant_id = :tenantId', { tenantId }); + + if (reportId) { + queryBuilder.andWhere('e.report_id = :reportId', { reportId }); + } + + if (dateFrom) { + queryBuilder.andWhere('e.executed_at >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('e.executed_at <= :dateTo', { dateTo }); + } + + const results = await queryBuilder + .select([ + 'COUNT(*) as total', + "SUM(CASE WHEN e.status = 'completed' THEN 1 ELSE 0 END) as completed", + "SUM(CASE WHEN e.status = 'failed' THEN 1 ELSE 0 END) as failed", + "SUM(CASE WHEN e.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled", + "AVG(CASE WHEN e.status = 'completed' THEN e.duration_ms ELSE NULL END) as average_duration", + 'SUM(COALESCE(e.row_count, 0)) as total_rows', + ]) + .getRawOne(); + + return { + total: parseInt(results.total || '0', 10), + completed: parseInt(results.completed || '0', 10), + failed: parseInt(results.failed || '0', 10), + cancelled: parseInt(results.cancelled || '0', 10), + averageDuration: parseFloat(results.average_duration || '0'), + totalRows: parseInt(results.total_rows || '0', 10), + }; + } + + async cleanupOldExecutions( + tenantId: string, + daysToKeep = 90 + ): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.executionRepository.delete({ + tenantId, + executedAt: LessThanOrEqual(cutoffDate), + }); + + return result.affected || 0; + } + + // ==================== Private Methods ==================== + + private async executeReportQuery( + report: Report, + parameters?: Record, + filters?: Record + ): Promise<{ + data: any[]; + rowCount: number; + filePath?: string; + fileSize?: number; + fileUrl?: string; + urlExpiresAt?: Date; + }> { + // This is a placeholder for actual report execution logic + // In a real implementation, this would: + // 1. Parse the report query configuration + // 2. Apply parameters and filters + // 3. Execute the query against the database + // 4. Generate the output file if needed + // 5. Return the results + + // For now, return placeholder data + return { + data: [], + rowCount: 0, + }; + } +} diff --git a/src/modules/reports/services/report-schedule.service.ts b/src/modules/reports/services/report-schedule.service.ts new file mode 100644 index 0000000..3068ec5 --- /dev/null +++ b/src/modules/reports/services/report-schedule.service.ts @@ -0,0 +1,377 @@ +/** + * Report Schedule Service + * Manages scheduled reports, recipients, and schedule executions + * + * @module Reports + */ + +import { Repository, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + ReportSchedule, + DeliveryMethod, + ScheduleExecutionStatus, + ScheduleExportFormat, +} from '../entities/report-schedule.entity'; +import { ReportRecipient } from '../entities/report-recipient.entity'; +import { ScheduleExecution } from '../entities/schedule-execution.entity'; + +export interface ScheduleSearchParams { + tenantId: string; + reportDefinitionId?: string; + isActive?: boolean; + deliveryMethod?: DeliveryMethod; + limit?: number; + offset?: number; +} + +export interface CreateScheduleDto { + reportDefinitionId: string; + name: string; + cronExpression: string; + timezone?: string; + parameters?: Record; + deliveryMethod?: DeliveryMethod; + deliveryConfig?: Record; + exportFormat?: ScheduleExportFormat; + isActive?: boolean; +} + +export interface UpdateScheduleDto extends Partial> {} + +export interface CreateRecipientDto { + scheduleId: string; + userId?: string | null; + email?: string | null; + name?: string | null; + isActive?: boolean; +} + +export class ReportScheduleService { + constructor( + private readonly scheduleRepository: Repository, + private readonly recipientRepository: Repository, + private readonly scheduleExecutionRepository: Repository + ) {} + + // ==================== Schedule CRUD ==================== + + async findAll(params: ScheduleSearchParams): Promise<{ data: ReportSchedule[]; total: number }> { + const { + tenantId, + reportDefinitionId, + isActive, + deliveryMethod, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (reportDefinitionId) { + where.reportDefinitionId = reportDefinitionId; + } + + if (isActive !== undefined) { + where.isActive = isActive; + } + + if (deliveryMethod) { + where.deliveryMethod = deliveryMethod; + } + + const [data, total] = await this.scheduleRepository.findAndCount({ + where, + relations: ['reportDefinition', 'recipients'], + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.scheduleRepository.findOne({ + where: { id, tenantId }, + relations: ['reportDefinition', 'recipients'], + }); + } + + async findByReportId( + reportDefinitionId: string, + tenantId: string + ): Promise { + return this.scheduleRepository.find({ + where: { reportDefinitionId, tenantId }, + relations: ['recipients'], + order: { name: 'ASC' }, + }); + } + + async create( + tenantId: string, + dto: CreateScheduleDto, + createdBy?: string + ): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + tenantId, + createdBy: createdBy || null, + nextRunAt: this.calculateNextRunAt(dto.cronExpression, dto.timezone), + }); + + return this.scheduleRepository.save(schedule); + } + + async update( + id: string, + tenantId: string, + dto: UpdateScheduleDto + ): Promise { + const schedule = await this.findOne(id, tenantId); + if (!schedule) return null; + + Object.assign(schedule, dto); + + // Recalculate next run if cron expression changed + if (dto.cronExpression) { + schedule.nextRunAt = this.calculateNextRunAt( + dto.cronExpression, + dto.timezone || schedule.timezone + ); + } + + return this.scheduleRepository.save(schedule); + } + + async delete(id: string, tenantId: string): Promise { + const schedule = await this.findOne(id, tenantId); + if (!schedule) return false; + + await this.scheduleRepository.delete(id); + return true; + } + + // ==================== Schedule Operations ==================== + + async schedule( + id: string, + tenantId: string, + cronExpression: string, + options?: { + timezone?: string; + deliveryMethod?: DeliveryMethod; + deliveryConfig?: Record; + } + ): Promise { + const schedule = await this.findOne(id, tenantId); + if (!schedule) return null; + + schedule.cronExpression = cronExpression; + schedule.isActive = true; + + if (options?.timezone) { + schedule.timezone = options.timezone; + } + + if (options?.deliveryMethod) { + schedule.deliveryMethod = options.deliveryMethod; + } + + if (options?.deliveryConfig) { + schedule.deliveryConfig = options.deliveryConfig; + } + + schedule.nextRunAt = this.calculateNextRunAt(cronExpression, schedule.timezone); + + return this.scheduleRepository.save(schedule); + } + + async toggleActive(id: string, tenantId: string): Promise { + const schedule = await this.findOne(id, tenantId); + if (!schedule) return null; + + schedule.isActive = !schedule.isActive; + + if (schedule.isActive) { + schedule.nextRunAt = this.calculateNextRunAt(schedule.cronExpression, schedule.timezone); + } else { + schedule.nextRunAt = null; + } + + return this.scheduleRepository.save(schedule); + } + + async getDueSchedules(): Promise { + const now = new Date(); + + return this.scheduleRepository.find({ + where: { + isActive: true, + nextRunAt: LessThanOrEqual(now), + }, + relations: ['reportDefinition', 'recipients'], + }); + } + + async updateLastRun( + id: string, + status: ScheduleExecutionStatus, + nextRunAt: Date | null + ): Promise { + await this.scheduleRepository.update(id, { + lastRunAt: new Date(), + lastRunStatus: status, + nextRunAt, + runCount: () => 'run_count + 1', + }); + } + + // ==================== Recipients CRUD ==================== + + async getRecipients(scheduleId: string): Promise { + return this.recipientRepository.find({ + where: { scheduleId }, + order: { name: 'ASC' }, + }); + } + + async addRecipient(dto: CreateRecipientDto): Promise { + // Validate that either userId or email is provided + if (!dto.userId && !dto.email) { + throw new Error('Either userId or email must be provided'); + } + + const recipient = this.recipientRepository.create(dto); + return this.recipientRepository.save(recipient); + } + + async updateRecipient( + id: string, + dto: Partial + ): Promise { + const recipient = await this.recipientRepository.findOne({ where: { id } }); + if (!recipient) return null; + + Object.assign(recipient, dto); + return this.recipientRepository.save(recipient); + } + + async removeRecipient(id: string): Promise { + const result = await this.recipientRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async setRecipients( + scheduleId: string, + recipients: Array<{ userId?: string; email?: string; name?: string }> + ): Promise { + // Remove existing recipients + await this.recipientRepository.delete({ scheduleId }); + + // Add new recipients + const newRecipients = recipients.map((r) => + this.recipientRepository.create({ + scheduleId, + userId: r.userId || null, + email: r.email || null, + name: r.name || null, + isActive: true, + }) + ); + + return this.recipientRepository.save(newRecipients); + } + + // ==================== Schedule Execution ==================== + + async createScheduleExecution( + scheduleId: string, + executionId: string | null, + status: ScheduleExecutionStatus + ): Promise { + const scheduleExecution = this.scheduleExecutionRepository.create({ + scheduleId, + executionId, + status, + executedAt: new Date(), + }); + + return this.scheduleExecutionRepository.save(scheduleExecution); + } + + async updateScheduleExecution( + id: string, + status: ScheduleExecutionStatus, + recipientsNotified: number, + deliveryStatus?: Record, + errorMessage?: string + ): Promise { + const scheduleExecution = await this.scheduleExecutionRepository.findOne({ + where: { id }, + }); + + if (!scheduleExecution) return null; + + scheduleExecution.status = status; + scheduleExecution.recipientsNotified = recipientsNotified; + + if (deliveryStatus) { + scheduleExecution.deliveryStatus = deliveryStatus; + } + + if (errorMessage) { + scheduleExecution.errorMessage = errorMessage; + } + + return this.scheduleExecutionRepository.save(scheduleExecution); + } + + async getScheduleExecutionHistory( + scheduleId: string, + limit = 20 + ): Promise { + return this.scheduleExecutionRepository.find({ + where: { scheduleId }, + relations: ['execution'], + order: { executedAt: 'DESC' }, + take: limit, + }); + } + + // ==================== History Operations ==================== + + async getHistory( + tenantId: string, + options?: { + scheduleId?: string; + limit?: number; + daysBack?: number; + } + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (options?.daysBack) { + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - options.daysBack); + where.lastRunAt = MoreThanOrEqual(dateFrom); + } + + return this.scheduleRepository.find({ + where, + relations: ['reportDefinition', 'scheduleExecutions'], + order: { lastRunAt: 'DESC' }, + take: options?.limit || 50, + }); + } + + // ==================== Private Methods ==================== + + private calculateNextRunAt(cronExpression: string, timezone?: string): Date | null { + // This would use a cron parser library like cron-parser + // For now, return a placeholder (next day at same time) + const next = new Date(); + next.setDate(next.getDate() + 1); + return next; + } +} diff --git a/src/modules/tarifas-transporte/__tests__/lanes.service.spec.ts b/src/modules/tarifas-transporte/__tests__/lanes.service.spec.ts new file mode 100644 index 0000000..ce6934e --- /dev/null +++ b/src/modules/tarifas-transporte/__tests__/lanes.service.spec.ts @@ -0,0 +1,502 @@ +/** + * @fileoverview Unit tests for LanesService + * Tests cover CRUD operations, findOrCreate, createReverse, validation, and error handling + */ + +import { Repository, SelectQueryBuilder, DeleteResult } from 'typeorm'; +import { Lane } from '../entities'; + +// Mock data +const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; +const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; +const mockLaneId = '550e8400-e29b-41d4-a716-446655440010'; + +const createMockLane = (overrides?: Partial): Partial => ({ + id: mockLaneId, + tenantId: mockTenantId, + codigo: 'MEX-GDL', + nombre: 'Mexico - Guadalajara', + origenCiudad: 'Mexico', + origenEstado: 'CDMX', + origenCodigoPostal: '06600', + destinoCiudad: 'Guadalajara', + destinoEstado: 'Jalisco', + destinoCodigoPostal: '44100', + distanciaKm: 540, + tiempoEstimadoHoras: 6, + activo: true, + createdAt: new Date('2026-01-01'), + createdById: mockUserId, + ...overrides, +}); + +describe('LanesService', () => { + let mockLaneRepository: Partial>; + let mockQueryBuilder: Partial>; + let lanesService: any; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Setup mock query builder + mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { ciudad: 'Mexico' }, + { ciudad: 'Guadalajara' }, + ]), + }; + + // Setup mock lane repository + mockLaneRepository = { + create: jest.fn().mockImplementation((data) => ({ ...createMockLane(), ...data })), + save: jest.fn().mockImplementation((lane) => Promise.resolve(lane)), + findOne: jest.fn().mockResolvedValue(createMockLane()), + find: jest.fn().mockResolvedValue([createMockLane()]), + findAndCount: jest.fn().mockResolvedValue([[createMockLane()], 1]), + delete: jest.fn().mockResolvedValue({ affected: 1 } as DeleteResult), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + // Import the service class and instantiate + const { LanesService } = await import('../services/lanes.service'); + lanesService = new LanesService(mockLaneRepository as Repository); + }); + + describe('findAll', () => { + it('should find all lanes with default params', async () => { + const result = await lanesService.findAll({ tenantId: mockTenantId }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + expect(result.data).toBeDefined(); + expect(result.total).toBe(1); + }); + + it('should filter by origenCiudad', async () => { + await lanesService.findAll({ tenantId: mockTenantId, origenCiudad: 'Mexico' }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by origenEstado', async () => { + await lanesService.findAll({ tenantId: mockTenantId, origenEstado: 'CDMX' }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by destinoCiudad', async () => { + await lanesService.findAll({ tenantId: mockTenantId, destinoCiudad: 'Guadalajara' }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by destinoEstado', async () => { + await lanesService.findAll({ tenantId: mockTenantId, destinoEstado: 'Jalisco' }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by activo', async () => { + await lanesService.findAll({ tenantId: mockTenantId, activo: true }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should apply search filter across multiple fields', async () => { + await lanesService.findAll({ tenantId: mockTenantId, search: 'MEX' }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should apply pagination', async () => { + await lanesService.findAll({ tenantId: mockTenantId, limit: 10, offset: 20 }); + + expect(mockLaneRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + skip: 20, + }) + ); + }); + }); + + describe('findOne', () => { + it('should find a lane by id', async () => { + const result = await lanesService.findOne(mockLaneId, mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockLaneId, tenantId: mockTenantId }, + }); + expect(result).toBeDefined(); + expect(result.codigo).toBe('MEX-GDL'); + }); + + it('should return null when lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.findOne('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('findByCodigo', () => { + it('should find a lane by codigo', async () => { + const result = await lanesService.findByCodigo('MEX-GDL', mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalledWith({ + where: { codigo: 'MEX-GDL', tenantId: mockTenantId }, + }); + expect(result).toBeDefined(); + }); + }); + + describe('findByOrigenDestino', () => { + it('should find a lane by origen and destino', async () => { + const result = await lanesService.findByOrigenDestino('Mexico', 'Guadalajara', mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('create', () => { + const createDto = { + codigo: 'GDL-MTY', + nombre: 'Guadalajara - Monterrey', + origenCiudad: 'Guadalajara', + origenEstado: 'Jalisco', + destinoCiudad: 'Monterrey', + destinoEstado: 'Nuevo Leon', + distanciaKm: 750, + tiempoEstimadoHoras: 8, + }; + + it('should create a new lane', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.create(mockTenantId, createDto, mockUserId); + + expect(mockLaneRepository.create).toHaveBeenCalled(); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when codigo already exists', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(createMockLane({ codigo: 'GDL-MTY' })); + + await expect( + lanesService.create(mockTenantId, createDto, mockUserId) + ).rejects.toThrow('Ya existe una lane con el codigo "GDL-MTY"'); + }); + }); + + describe('update', () => { + const updateDto = { + nombre: 'Mexico - Guadalajara (Actualizada)', + distanciaKm: 545, + }; + + it('should update an existing lane', async () => { + const result = await lanesService.update(mockLaneId, mockTenantId, updateDto, mockUserId); + + expect(mockLaneRepository.findOne).toHaveBeenCalled(); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.update('non-existent-id', mockTenantId, updateDto, mockUserId); + + expect(result).toBeNull(); + }); + + it('should check for codigo uniqueness when changing codigo', async () => { + const mockExisting = createMockLane(); + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(mockExisting) // First call: find by id + .mockResolvedValueOnce(createMockLane({ id: 'other-id', codigo: 'LANE-EXISTING' })); // Second call: find by codigo + + await expect( + lanesService.update(mockLaneId, mockTenantId, { codigo: 'LANE-EXISTING' }, mockUserId) + ).rejects.toThrow('Ya existe una lane con el codigo "LANE-EXISTING"'); + }); + }); + + describe('activar / desactivar', () => { + it('should activate a lane', async () => { + const result = await lanesService.activar(mockLaneId, mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalled(); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should deactivate a lane', async () => { + const result = await lanesService.desactivar(mockLaneId, mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalled(); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.activar('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('should delete a lane', async () => { + const result = await lanesService.delete(mockLaneId, mockTenantId); + + expect(mockLaneRepository.findOne).toHaveBeenCalled(); + expect(mockLaneRepository.delete).toHaveBeenCalledWith(mockLaneId); + expect(result).toBe(true); + }); + + it('should return false when lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.delete('non-existent-id', mockTenantId); + + expect(result).toBe(false); + }); + }); + + describe('getLanesByOrigenEstado', () => { + it('should get all active lanes by origin state', async () => { + const result = await lanesService.getLanesByOrigenEstado('CDMX', mockTenantId); + + expect(mockLaneRepository.find).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('getLanesByDestinoEstado', () => { + it('should get all active lanes by destination state', async () => { + const result = await lanesService.getLanesByDestinoEstado('Jalisco', mockTenantId); + + expect(mockLaneRepository.find).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('getOrigenCiudades', () => { + it('should get all unique origin cities', async () => { + const result = await lanesService.getOrigenCiudades(mockTenantId); + + expect(mockLaneRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT l.origen_ciudad', 'ciudad'); + expect(result).toEqual(['Mexico', 'Guadalajara']); + }); + }); + + describe('getDestinoCiudades', () => { + it('should get all unique destination cities', async () => { + const result = await lanesService.getDestinoCiudades(mockTenantId); + + expect(mockLaneRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT l.destino_ciudad', 'ciudad'); + expect(result).toBeDefined(); + }); + }); + + describe('getOrigenEstados', () => { + it('should get all unique origin states', async () => { + mockQueryBuilder.getRawMany = jest.fn().mockResolvedValue([ + { estado: 'CDMX' }, + { estado: 'Jalisco' }, + ]); + + const result = await lanesService.getOrigenEstados(mockTenantId); + + expect(mockLaneRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT l.origen_estado', 'estado'); + expect(result).toEqual(['CDMX', 'Jalisco']); + }); + }); + + describe('getDestinoEstados', () => { + it('should get all unique destination states', async () => { + mockQueryBuilder.getRawMany = jest.fn().mockResolvedValue([ + { estado: 'Jalisco' }, + { estado: 'Nuevo Leon' }, + ]); + + const result = await lanesService.getDestinoEstados(mockTenantId); + + expect(mockLaneRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('DISTINCT l.destino_estado', 'estado'); + expect(result).toEqual(['Jalisco', 'Nuevo Leon']); + }); + }); + + describe('findOrCreate', () => { + const createDto = { + codigo: 'MTY-GDL', + nombre: 'Monterrey - Guadalajara', + origenCiudad: 'Monterrey', + origenEstado: 'Nuevo Leon', + destinoCiudad: 'Guadalajara', + destinoEstado: 'Jalisco', + }; + + it('should return existing lane when found by origen-destino', async () => { + const existingLane = createMockLane({ + origenCiudad: 'Monterrey', + destinoCiudad: 'Guadalajara', + }); + mockLaneRepository.findOne = jest.fn().mockResolvedValueOnce(existingLane); + + const result = await lanesService.findOrCreate(mockTenantId, createDto, mockUserId); + + expect(result.created).toBe(false); + expect(result.lane).toBeDefined(); + }); + + it('should return existing lane when found by codigo', async () => { + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(null) // origen-destino not found + .mockResolvedValueOnce(createMockLane({ codigo: 'MTY-GDL' })); // codigo found + + const result = await lanesService.findOrCreate(mockTenantId, createDto, mockUserId); + + expect(result.created).toBe(false); + expect(result.lane).toBeDefined(); + }); + + it('should create new lane when not found', async () => { + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(null) // origen-destino not found + .mockResolvedValueOnce(null) // codigo not found + .mockResolvedValueOnce(null); // create check + + const result = await lanesService.findOrCreate(mockTenantId, createDto, mockUserId); + + expect(mockLaneRepository.create).toHaveBeenCalled(); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result.created).toBe(true); + }); + }); + + describe('getLaneInversa', () => { + it('should find the reverse lane', async () => { + const originalLane = createMockLane(); + const reverseLane = createMockLane({ + id: '550e8400-e29b-41d4-a716-446655440020', + codigo: 'GDL-MEX', + origenCiudad: 'Guadalajara', + destinoCiudad: 'Mexico', + }); + + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(originalLane) // find original + .mockResolvedValueOnce(reverseLane); // find reverse + + const result = await lanesService.getLaneInversa(mockLaneId, mockTenantId); + + expect(result).toBeDefined(); + expect(result.origenCiudad).toBe('Guadalajara'); + expect(result.destinoCiudad).toBe('Mexico'); + }); + + it('should return null when original lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.getLaneInversa('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + + it('should return null when reverse lane does not exist', async () => { + const originalLane = createMockLane(); + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(originalLane) // find original + .mockResolvedValueOnce(null); // reverse not found + + const result = await lanesService.getLaneInversa(mockLaneId, mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('crearLaneInversa', () => { + it('should create reverse lane', async () => { + const originalLane = createMockLane(); + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(originalLane) // find original + .mockResolvedValueOnce(null) // reverse does not exist + .mockResolvedValueOnce(null); // codigo check for create + + const result = await lanesService.crearLaneInversa(mockLaneId, mockTenantId, mockUserId); + + expect(mockLaneRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + codigo: 'MEX-GDL-REV', + origenCiudad: 'Guadalajara', + origenEstado: 'Jalisco', + destinoCiudad: 'Mexico', + destinoEstado: 'CDMX', + }) + ); + expect(mockLaneRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return existing reverse lane if already exists', async () => { + const originalLane = createMockLane(); + const existingReverse = createMockLane({ + id: '550e8400-e29b-41d4-a716-446655440020', + codigo: 'GDL-MEX', + origenCiudad: 'Guadalajara', + destinoCiudad: 'Mexico', + }); + + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(originalLane) // find original + .mockResolvedValueOnce(existingReverse); // reverse already exists + + const result = await lanesService.crearLaneInversa(mockLaneId, mockTenantId, mockUserId); + + expect(mockLaneRepository.create).not.toHaveBeenCalled(); + expect(result).toEqual(existingReverse); + }); + + it('should return null when original lane not found', async () => { + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await lanesService.crearLaneInversa('non-existent-id', mockTenantId, mockUserId); + + expect(result).toBeNull(); + }); + + it('should preserve distance and time when creating reverse', async () => { + const originalLane = createMockLane({ + distanciaKm: 540, + tiempoEstimadoHoras: 6, + }); + mockLaneRepository.findOne = jest.fn() + .mockResolvedValueOnce(originalLane) // find original + .mockResolvedValueOnce(null) // reverse does not exist + .mockResolvedValueOnce(null); // codigo check + + await lanesService.crearLaneInversa(mockLaneId, mockTenantId, mockUserId); + + expect(mockLaneRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + distanciaKm: 540, + tiempoEstimadoHoras: 6, + }) + ); + }); + }); +}); diff --git a/src/modules/tarifas-transporte/__tests__/tarifas.service.spec.ts b/src/modules/tarifas-transporte/__tests__/tarifas.service.spec.ts new file mode 100644 index 0000000..739b9e9 --- /dev/null +++ b/src/modules/tarifas-transporte/__tests__/tarifas.service.spec.ts @@ -0,0 +1,610 @@ +/** + * @fileoverview Unit tests for TarifasService + * Tests cover CRUD operations, cotizar calculations, validation, and error handling + */ + +import { Repository, SelectQueryBuilder, DeleteResult } from 'typeorm'; +import { Tarifa, TipoTarifa, Lane } from '../entities'; + +// Mock data +const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; +const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; +const mockLaneId = '550e8400-e29b-41d4-a716-446655440003'; +const mockClienteId = '550e8400-e29b-41d4-a716-446655440004'; +const mockTarifaId = '550e8400-e29b-41d4-a716-446655440010'; + +const mockLane: Partial = { + id: mockLaneId, + tenantId: mockTenantId, + codigo: 'MEX-GDL', + nombre: 'Mexico - Guadalajara', + origenCiudad: 'Mexico', + origenEstado: 'CDMX', + destinoCiudad: 'Guadalajara', + destinoEstado: 'Jalisco', + distanciaKm: 540, + tiempoEstimadoHoras: 6, + activo: true, + createdAt: new Date('2026-01-01'), + createdById: mockUserId, +}; + +const createMockTarifa = (overrides?: Partial): Partial => ({ + id: mockTarifaId, + tenantId: mockTenantId, + codigo: 'TAR-001', + nombre: 'Tarifa General FTL', + descripcion: 'Tarifa general para carga completa', + clienteId: null, + laneId: mockLaneId, + modalidadServicio: 'FTL', + tipoEquipo: '53FT', + tipoTarifa: TipoTarifa.POR_KM, + tarifaBase: 5000, + tarifaKm: 12.5, + tarifaTonelada: null, + tarifaM3: null, + tarifaPallet: null, + tarifaHora: null, + minimoFacturar: 3000, + pesoMinimoKg: null, + moneda: 'MXN', + fechaInicio: new Date('2026-01-01'), + fechaFin: null, + activa: true, + createdAt: new Date('2026-01-01'), + createdById: mockUserId, + updatedAt: new Date('2026-01-01'), + lane: mockLane as Lane, + // Mock the vigente getter + get vigente() { + const hoy = new Date(); + const inicio = new Date(this.fechaInicio as Date); + const fin = this.fechaFin ? new Date(this.fechaFin) : null; + return this.activa && hoy >= inicio && (!fin || hoy <= fin); + }, + ...overrides, +}); + +describe('TarifasService', () => { + let mockTarifaRepository: Partial>; + let mockLaneRepository: Partial>; + let mockQueryBuilder: Partial>; + let tarifasService: any; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Setup mock query builder + mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[createMockTarifa()], 1]), + getMany: jest.fn().mockResolvedValue([createMockTarifa()]), + getOne: jest.fn().mockResolvedValue(createMockTarifa()), + getCount: jest.fn().mockResolvedValue(1), + clone: jest.fn().mockReturnThis(), + }; + + // Setup mock tarifa repository + mockTarifaRepository = { + create: jest.fn().mockImplementation((data) => ({ ...createMockTarifa(), ...data })), + save: jest.fn().mockImplementation((tarifa) => Promise.resolve(tarifa)), + findOne: jest.fn().mockResolvedValue(createMockTarifa()), + find: jest.fn().mockResolvedValue([createMockTarifa()]), + delete: jest.fn().mockResolvedValue({ affected: 1 } as DeleteResult), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + // Setup mock lane repository + mockLaneRepository = { + findOne: jest.fn().mockResolvedValue(mockLane), + }; + + // Import the service class and instantiate + const { TarifasService } = await import('../services/tarifas.service'); + tarifasService = new TarifasService( + mockTarifaRepository as Repository, + mockLaneRepository as Repository + ); + }); + + describe('findAll', () => { + it('should find all tarifas with default params', async () => { + const result = await tarifasService.findAll({ tenantId: mockTenantId }); + + expect(mockTarifaRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('t.tenant_id = :tenantId', { tenantId: mockTenantId }); + expect(mockQueryBuilder.getManyAndCount).toHaveBeenCalled(); + expect(result.data).toBeDefined(); + expect(result.total).toBe(1); + }); + + it('should filter by clienteId', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, clienteId: mockClienteId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.cliente_id = :clienteId', { clienteId: mockClienteId }); + }); + + it('should filter by laneId', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, laneId: mockLaneId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.lane_id = :laneId', { laneId: mockLaneId }); + }); + + it('should filter by tipoTarifa', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, tipoTarifa: TipoTarifa.POR_KM }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.tipo_tarifa = :tipoTarifa', { tipoTarifa: TipoTarifa.POR_KM }); + }); + + it('should filter by activa', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, activa: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.activa = :activa', { activa: true }); + }); + + it('should filter by vigente', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, vigente: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.activa = true'); + }); + + it('should filter by moneda', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, moneda: 'USD' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.moneda = :moneda', { moneda: 'USD' }); + }); + + it('should filter by search term', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, search: 'FTL' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(t.codigo ILIKE :search OR t.nombre ILIKE :search OR t.descripcion ILIKE :search)', + { search: '%FTL%' } + ); + }); + + it('should apply pagination', async () => { + await tarifasService.findAll({ tenantId: mockTenantId, limit: 10, offset: 20 }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20); + }); + }); + + describe('findOne', () => { + it('should find a tarifa by id', async () => { + const result = await tarifasService.findOne(mockTarifaId, mockTenantId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockTarifaId, tenantId: mockTenantId }, + relations: ['lane'], + }); + expect(result).toBeDefined(); + expect(result.codigo).toBe('TAR-001'); + }); + + it('should return null when tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.findOne('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('findByCodigo', () => { + it('should find a tarifa by codigo', async () => { + const result = await tarifasService.findByCodigo('TAR-001', mockTenantId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalledWith({ + where: { codigo: 'TAR-001', tenantId: mockTenantId }, + relations: ['lane'], + }); + expect(result).toBeDefined(); + }); + }); + + describe('create', () => { + const createDto = { + codigo: 'TAR-002', + nombre: 'Nueva Tarifa', + tipoTarifa: TipoTarifa.POR_VIAJE, + tarifaBase: 10000, + fechaInicio: new Date('2026-02-01'), + }; + + it('should create a new tarifa', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.create(mockTenantId, createDto, mockUserId); + + expect(mockTarifaRepository.create).toHaveBeenCalled(); + expect(mockTarifaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should throw error when codigo already exists', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(createMockTarifa({ codigo: 'TAR-002' })); + + await expect( + tarifasService.create(mockTenantId, createDto, mockUserId) + ).rejects.toThrow('Ya existe una tarifa con el codigo "TAR-002"'); + }); + + it('should validate lane if provided', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + mockLaneRepository.findOne = jest.fn().mockResolvedValue(mockLane); + + const dtoWithLane = { ...createDto, laneId: mockLaneId }; + await tarifasService.create(mockTenantId, dtoWithLane, mockUserId); + + expect(mockLaneRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockLaneId, tenantId: mockTenantId }, + }); + }); + + it('should throw error when lane not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + mockLaneRepository.findOne = jest.fn().mockResolvedValue(null); + + const dtoWithLane = { ...createDto, laneId: 'non-existent-lane' }; + + await expect( + tarifasService.create(mockTenantId, dtoWithLane, mockUserId) + ).rejects.toThrow('Lane no encontrada'); + }); + }); + + describe('update', () => { + const updateDto = { + nombre: 'Tarifa Actualizada', + tarifaBase: 6000, + }; + + it('should update an existing tarifa', async () => { + const result = await tarifasService.update(mockTarifaId, mockTenantId, updateDto, mockUserId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalled(); + expect(mockTarifaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.update('non-existent-id', mockTenantId, updateDto, mockUserId); + + expect(result).toBeNull(); + }); + + it('should check for codigo uniqueness when changing codigo', async () => { + const mockExisting = createMockTarifa(); + mockTarifaRepository.findOne = jest.fn() + .mockResolvedValueOnce(mockExisting) // First call: find by id + .mockResolvedValueOnce(createMockTarifa({ id: 'other-id', codigo: 'TAR-EXISTING' })); // Second call: find by codigo + + await expect( + tarifasService.update(mockTarifaId, mockTenantId, { codigo: 'TAR-EXISTING' }, mockUserId) + ).rejects.toThrow('Ya existe una tarifa con el codigo "TAR-EXISTING"'); + }); + }); + + describe('activar / desactivar', () => { + it('should activate a tarifa', async () => { + const result = await tarifasService.activar(mockTarifaId, mockTenantId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalled(); + expect(mockTarifaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should deactivate a tarifa', async () => { + const result = await tarifasService.desactivar(mockTarifaId, mockTenantId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalled(); + expect(mockTarifaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.activar('non-existent-id', mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('should delete a tarifa', async () => { + const result = await tarifasService.delete(mockTarifaId, mockTenantId); + + expect(mockTarifaRepository.findOne).toHaveBeenCalled(); + expect(mockTarifaRepository.delete).toHaveBeenCalledWith(mockTarifaId); + expect(result).toBe(true); + }); + + it('should return false when tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.delete('non-existent-id', mockTenantId); + + expect(result).toBe(false); + }); + }); + + describe('cotizar', () => { + it('should calculate quote for POR_KM tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_KM, + tarifaBase: 5000, + tarifaKm: 10, + minimoFacturar: null, + }); + // Add vigente getter + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { km: 500 }); + + expect(result.montoCalculado).toBe(10000); // 5000 + (500 * 10) + expect(result.desglose.tarifaBase).toBe(5000); + expect(result.desglose.montoVariable).toBe(5000); + expect(result.desglose.minimoAplicado).toBe(false); + }); + + it('should calculate quote for POR_TONELADA tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_TONELADA, + tarifaBase: 2000, + tarifaTonelada: 150, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { toneladas: 20 }); + + expect(result.montoCalculado).toBe(5000); // 2000 + (20 * 150) + expect(result.desglose.montoVariable).toBe(3000); + }); + + it('should calculate quote for POR_M3 tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_M3, + tarifaBase: 1000, + tarifaM3: 50, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { m3: 30 }); + + expect(result.montoCalculado).toBe(2500); // 1000 + (30 * 50) + }); + + it('should calculate quote for POR_PALLET tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_PALLET, + tarifaBase: 500, + tarifaPallet: 200, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { pallets: 15 }); + + expect(result.montoCalculado).toBe(3500); // 500 + (15 * 200) + }); + + it('should calculate quote for POR_HORA tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_HORA, + tarifaBase: 1000, + tarifaHora: 300, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { horas: 8 }); + + expect(result.montoCalculado).toBe(3400); // 1000 + (8 * 300) + }); + + it('should calculate quote for MIXTA tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.MIXTA, + tarifaBase: 3000, + tarifaKm: 5, + tarifaTonelada: 100, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { km: 400, toneladas: 10 }); + + expect(result.montoCalculado).toBe(6000); // 3000 + (400 * 5) + (10 * 100) + expect(result.desglose.montoVariable).toBe(3000); + }); + + it('should calculate quote for POR_VIAJE tariff', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_VIAJE, + tarifaBase: 15000, + minimoFacturar: null, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, {}); + + expect(result.montoCalculado).toBe(15000); + expect(result.desglose.montoVariable).toBe(0); + }); + + it('should apply minimum when calculated amount is lower', async () => { + const mockTarifa = createMockTarifa({ + tipoTarifa: TipoTarifa.POR_KM, + tarifaBase: 1000, + tarifaKm: 5, + minimoFacturar: 5000, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => true }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + const result = await tarifasService.cotizar(mockTarifaId, mockTenantId, { km: 100 }); + + expect(result.montoCalculado).toBe(5000); // Minimo applied (1000 + 500 = 1500 < 5000) + expect(result.desglose.minimoAplicado).toBe(true); + }); + + it('should throw error when tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect( + tarifasService.cotizar('non-existent-id', mockTenantId, { km: 100 }) + ).rejects.toThrow('Tarifa no encontrada'); + }); + + it('should throw error when tarifa is not vigente', async () => { + const mockTarifa = createMockTarifa({ + activa: false, + }); + Object.defineProperty(mockTarifa, 'vigente', { get: () => false }); + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(mockTarifa); + + await expect( + tarifasService.cotizar(mockTarifaId, mockTenantId, { km: 100 }) + ).rejects.toThrow('La tarifa no esta vigente'); + }); + }); + + describe('getTarifaAplicable', () => { + it('should find client-specific tarifa with lane priority', async () => { + const clientLaneTarifa = createMockTarifa({ clienteId: mockClienteId, laneId: mockLaneId }); + mockQueryBuilder.getOne = jest.fn().mockResolvedValueOnce(clientLaneTarifa); + + const result = await tarifasService.getTarifaAplicable(mockTenantId, { + clienteId: mockClienteId, + laneId: mockLaneId, + }); + + expect(result).toBeDefined(); + expect(mockQueryBuilder.clone).toHaveBeenCalled(); + }); + + it('should fallback to general tarifa when no specific found', async () => { + mockQueryBuilder.getOne = jest.fn() + .mockResolvedValueOnce(null) // client + lane + .mockResolvedValueOnce(null) // client only + .mockResolvedValueOnce(null) // lane only + .mockResolvedValueOnce(createMockTarifa({ clienteId: null, laneId: null })); // general + + const result = await tarifasService.getTarifaAplicable(mockTenantId, { + clienteId: mockClienteId, + laneId: mockLaneId, + }); + + expect(result).toBeDefined(); + }); + }); + + describe('getTarifasCliente', () => { + it('should get all active tarifas for a client', async () => { + const result = await tarifasService.getTarifasCliente(mockClienteId, mockTenantId); + + expect(mockTarifaRepository.find).toHaveBeenCalledWith({ + where: { clienteId: mockClienteId, tenantId: mockTenantId, activa: true }, + relations: ['lane'], + order: { fechaInicio: 'DESC' }, + }); + expect(result).toBeDefined(); + }); + }); + + describe('getTarifasLane', () => { + it('should get all active tarifas for a lane', async () => { + const result = await tarifasService.getTarifasLane(mockLaneId, mockTenantId); + + expect(mockTarifaRepository.find).toHaveBeenCalledWith({ + where: { laneId: mockLaneId, tenantId: mockTenantId, activa: true }, + relations: ['lane'], + order: { fechaInicio: 'DESC' }, + }); + expect(result).toBeDefined(); + }); + }); + + describe('getTarifasPorVencer', () => { + it('should get tarifas expiring within specified days', async () => { + mockQueryBuilder.getMany = jest.fn().mockResolvedValue([createMockTarifa()]); + + const result = await tarifasService.getTarifasPorVencer(mockTenantId, 30); + + expect(mockTarifaRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.activa = true'); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('t.fecha_fin IS NOT NULL'); + expect(result).toBeDefined(); + }); + }); + + describe('clonarTarifa', () => { + it('should clone a tarifa with new validity period', async () => { + mockTarifaRepository.findOne = jest.fn() + .mockResolvedValueOnce(createMockTarifa()) // source tarifa + .mockResolvedValueOnce(null); // codigo check + + const result = await tarifasService.clonarTarifa( + mockTarifaId, + mockTenantId, + 'TAR-002-CLONE', + new Date('2027-01-01'), + new Date('2027-12-31'), + mockUserId + ); + + expect(mockTarifaRepository.create).toHaveBeenCalled(); + expect(mockTarifaRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should return null when source tarifa not found', async () => { + mockTarifaRepository.findOne = jest.fn().mockResolvedValue(null); + + const result = await tarifasService.clonarTarifa( + 'non-existent-id', + mockTenantId, + 'TAR-002-CLONE', + new Date('2027-01-01') + ); + + expect(result).toBeNull(); + }); + + it('should throw error when new codigo already exists', async () => { + mockTarifaRepository.findOne = jest.fn() + .mockResolvedValueOnce(createMockTarifa()) // source tarifa + .mockResolvedValueOnce(createMockTarifa({ codigo: 'TAR-EXISTING' })); // codigo exists + + await expect( + tarifasService.clonarTarifa( + mockTarifaId, + mockTenantId, + 'TAR-EXISTING', + new Date('2027-01-01') + ) + ).rejects.toThrow('Ya existe una tarifa con el codigo "TAR-EXISTING"'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 10327a5..718c4af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"] }