diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 2afcd75..6d74a1e 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -6,3 +6,9 @@ export { default as authRoutes } from './auth.routes.js'; export * from './apiKeys.service.js'; export * from './apiKeys.controller.js'; export { default as apiKeysRoutes } from './apiKeys.routes.js'; + +// Entities +export * from './entities/index.js'; + +// Additional Services (roles, permissions) +export * from './services/index.js'; diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts new file mode 100644 index 0000000..ab748cc --- /dev/null +++ b/src/modules/auth/services/index.ts @@ -0,0 +1,7 @@ +/** + * Auth Services + * Roles, permisos, autenticacion + */ +export * from './token.service.js'; +export * from './roles.service.js'; +export * from './permissions.service.js'; diff --git a/src/modules/auth/services/permissions.service.ts b/src/modules/auth/services/permissions.service.ts new file mode 100644 index 0000000..6cdab09 --- /dev/null +++ b/src/modules/auth/services/permissions.service.ts @@ -0,0 +1,292 @@ +import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Permission, PermissionAction, Role } from '../entities/index.js'; + +export interface PermissionSearchParams { + search?: string; + resource?: string; + action?: PermissionAction; + module?: string; + limit?: number; + offset?: number; +} + +export interface CreatePermissionDto { + resource: string; + action: PermissionAction; + description?: string; + module?: string; +} + +export interface UpdatePermissionDto { + description?: string; + module?: string; +} + +export interface BulkCreatePermissionsDto { + resource: string; + actions: PermissionAction[]; + module?: string; +} + +export class PermissionsService { + constructor( + private readonly permissionRepository: Repository, + private readonly roleRepository: Repository, + ) {} + + async findAll(params: PermissionSearchParams): Promise<{ data: Permission[]; total: number }> { + const { + search, + resource, + action, + module, + limit = 100, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = {}; + + if (resource) { + baseWhere.resource = resource; + } + + if (action) { + baseWhere.action = action; + } + + if (module) { + baseWhere.module = module; + } + + if (search) { + where.push( + { ...baseWhere, resource: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) }, + { ...baseWhere, module: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.permissionRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string): Promise { + return this.permissionRepository.findOne({ + where: { id }, + relations: ['roles'], + }); + } + + async findByResourceAction(resource: string, action: PermissionAction): Promise { + return this.permissionRepository.findOne({ + where: { resource, action }, + }); + } + + async findByModule(module: string): Promise { + return this.permissionRepository.find({ + where: { module }, + order: { resource: 'ASC', action: 'ASC' }, + }); + } + + async findByResource(resource: string): Promise { + return this.permissionRepository.find({ + where: { resource }, + order: { action: 'ASC' }, + }); + } + + async create(dto: CreatePermissionDto): Promise { + // Check if permission already exists + const existing = await this.findByResourceAction(dto.resource, dto.action); + if (existing) { + throw new Error(`Ya existe el permiso ${dto.action} para el recurso ${dto.resource}`); + } + + const permission = this.permissionRepository.create({ + resource: dto.resource, + action: dto.action, + description: dto.description ?? null, + module: dto.module ?? null, + }); + + return this.permissionRepository.save(permission); + } + + async update(id: string, dto: UpdatePermissionDto): Promise { + const permission = await this.findOne(id); + if (!permission) return null; + + if (dto.description !== undefined) { + permission.description = dto.description ?? null; + } + if (dto.module !== undefined) { + permission.module = dto.module ?? null; + } + + return this.permissionRepository.save(permission); + } + + // Create multiple permissions for a resource (CRUD operations) + async bulkCreateForResource(dto: BulkCreatePermissionsDto): Promise { + const created: Permission[] = []; + + for (const action of dto.actions) { + const existing = await this.findByResourceAction(dto.resource, action); + if (!existing) { + const permission = this.permissionRepository.create({ + resource: dto.resource, + action, + description: `${action} ${dto.resource}`, + module: dto.module ?? null, + }); + const saved = await this.permissionRepository.save(permission); + created.push(saved); + } + } + + return created; + } + + // Create standard CRUD permissions for a resource + async createCrudPermissions(resource: string, module?: string): Promise { + const crudActions = [ + PermissionAction.CREATE, + PermissionAction.READ, + PermissionAction.UPDATE, + PermissionAction.DELETE, + ]; + + return this.bulkCreateForResource({ + resource, + actions: crudActions, + module, + }); + } + + // Get all unique resources + async getResources(): Promise { + const permissions = await this.permissionRepository + .createQueryBuilder('p') + .select('DISTINCT p.resource', 'resource') + .orderBy('p.resource', 'ASC') + .getRawMany(); + + return permissions.map(p => p.resource); + } + + // Get all unique modules + async getModules(): Promise { + const permissions = await this.permissionRepository + .createQueryBuilder('p') + .select('DISTINCT p.module', 'module') + .where('p.module IS NOT NULL') + .orderBy('p.module', 'ASC') + .getRawMany(); + + return permissions.map(p => p.module); + } + + // Get permissions grouped by module + async getPermissionsByModule(): Promise> { + const permissions = await this.permissionRepository.find({ + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + + const grouped = new Map(); + + for (const permission of permissions) { + const module = permission.module ?? 'other'; + if (!grouped.has(module)) { + grouped.set(module, []); + } + grouped.get(module)!.push(permission); + } + + return grouped; + } + + // Get permissions grouped by resource + async getPermissionsByResource(): Promise> { + const permissions = await this.permissionRepository.find({ + order: { resource: 'ASC', action: 'ASC' }, + }); + + const grouped = new Map(); + + for (const permission of permissions) { + if (!grouped.has(permission.resource)) { + grouped.set(permission.resource, []); + } + grouped.get(permission.resource)!.push(permission); + } + + return grouped; + } + + // Check if a role has a specific permission + async roleHasPermission( + roleId: string, + resource: string, + action: PermissionAction + ): Promise { + const permission = await this.findByResourceAction(resource, action); + if (!permission) return false; + + const role = await this.roleRepository.findOne({ + where: { id: roleId }, + relations: ['permissions'], + }); + + if (!role) return false; + + return role.permissions.some(p => p.id === permission.id); + } + + // Get roles that have a specific permission + async getRolesWithPermission(permissionId: string): Promise { + const permission = await this.findOne(permissionId); + if (!permission) return []; + + return permission.roles ?? []; + } + + async delete(id: string): Promise { + const permission = await this.findOne(id); + if (!permission) return false; + + // Check if permission is assigned to any roles + if (permission.roles && permission.roles.length > 0) { + throw new Error('No se puede eliminar un permiso asignado a roles. Primero quite el permiso de los roles.'); + } + + const result = await this.permissionRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // Delete all permissions for a resource + async deleteByResource(resource: string): Promise { + const permissions = await this.findByResource(resource); + + // Check if any permission is assigned to roles + for (const permission of permissions) { + const fullPermission = await this.findOne(permission.id); + if (fullPermission?.roles && fullPermission.roles.length > 0) { + throw new Error(`El permiso ${permission.action} de ${resource} esta asignado a roles`); + } + } + + const result = await this.permissionRepository.delete({ resource }); + return result.affected ?? 0; + } +} diff --git a/src/modules/auth/services/roles.service.ts b/src/modules/auth/services/roles.service.ts new file mode 100644 index 0000000..b80dfae --- /dev/null +++ b/src/modules/auth/services/roles.service.ts @@ -0,0 +1,302 @@ +import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Role, Permission, User } from '../entities/index.js'; + +export interface RoleSearchParams { + tenantId: string; + search?: string; + isSystem?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateRoleDto { + name: string; + code: string; + description?: string; + isSystem?: boolean; + color?: string; + permissionIds?: string[]; +} + +export interface UpdateRoleDto extends Partial {} + +export interface AssignPermissionsDto { + permissionIds: string[]; +} + +export class RolesService { + constructor( + private readonly roleRepository: Repository, + private readonly permissionRepository: Repository, + ) {} + + async findAll(params: RoleSearchParams): Promise<{ data: Role[]; total: number }> { + const { + tenantId, + search, + isSystem, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (isSystem !== undefined) { + baseWhere.isSystem = isSystem; + } + + 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.roleRepository.findAndCount({ + where, + relations: ['permissions'], + take: limit, + skip: offset, + order: { isSystem: 'DESC', name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.roleRepository.findOne({ + where: { id, tenantId }, + relations: ['permissions', 'users'], + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.roleRepository.findOne({ + where: { code, tenantId }, + relations: ['permissions'], + }); + } + + async create(tenantId: string, dto: CreateRoleDto, createdBy: string): Promise { + // Check if code already exists + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error(`Ya existe un rol con el codigo "${dto.code}"`); + } + + const role = this.roleRepository.create({ + tenantId, + name: dto.name, + code: dto.code, + description: dto.description ?? null, + isSystem: dto.isSystem ?? false, + color: dto.color ?? null, + createdBy, + }); + + // If permissions were provided, assign them + if (dto.permissionIds && dto.permissionIds.length > 0) { + const permissions = await this.permissionRepository.findBy({ + id: In(dto.permissionIds), + }); + role.permissions = permissions; + } + + return this.roleRepository.save(role); + } + + async update( + id: string, + tenantId: string, + dto: UpdateRoleDto, + updatedBy: string + ): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return null; + + // Cannot modify system roles + if (role.isSystem && !dto.description && !dto.color) { + throw new Error('Los roles del sistema solo permiten modificar descripcion y color'); + } + + // Check for code uniqueness if changing code + if (dto.code && dto.code !== role.code) { + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode && existingCode.id !== id) { + throw new Error(`Ya existe un rol con el codigo "${dto.code}"`); + } + } + + // Update basic fields + if (dto.name !== undefined && !role.isSystem) role.name = dto.name; + if (dto.code !== undefined && !role.isSystem) role.code = dto.code; + if (dto.description !== undefined) role.description = dto.description ?? null; + if (dto.color !== undefined) role.color = dto.color ?? null; + role.updatedBy = updatedBy; + + // Update permissions if provided + if (dto.permissionIds !== undefined) { + const permissions = await this.permissionRepository.findBy({ + id: In(dto.permissionIds), + }); + role.permissions = permissions; + } + + return this.roleRepository.save(role); + } + + async assignPermissions( + id: string, + tenantId: string, + dto: AssignPermissionsDto, + updatedBy: string + ): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return null; + + const permissions = await this.permissionRepository.findBy({ + id: In(dto.permissionIds), + }); + + role.permissions = permissions; + role.updatedBy = updatedBy; + + return this.roleRepository.save(role); + } + + async addPermission( + id: string, + tenantId: string, + permissionId: string, + updatedBy: string + ): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return null; + + const permission = await this.permissionRepository.findOne({ + where: { id: permissionId }, + }); + if (!permission) { + throw new Error('Permiso no encontrado'); + } + + // Check if permission already assigned + const hasPermission = role.permissions.some(p => p.id === permissionId); + if (!hasPermission) { + role.permissions.push(permission); + role.updatedBy = updatedBy; + return this.roleRepository.save(role); + } + + return role; + } + + async removePermission( + id: string, + tenantId: string, + permissionId: string, + updatedBy: string + ): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return null; + + role.permissions = role.permissions.filter(p => p.id !== permissionId); + role.updatedBy = updatedBy; + + return this.roleRepository.save(role); + } + + async getPermissions(id: string, tenantId: string): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return []; + + return role.permissions; + } + + async getUsers(id: string, tenantId: string): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return []; + + return role.users; + } + + async getUserCount(id: string, tenantId: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id, tenantId }, + relations: ['users'], + }); + + return role?.users?.length ?? 0; + } + + // Get system roles (admin, user, etc.) + async getSystemRoles(tenantId: string): Promise { + return this.roleRepository.find({ + where: { tenantId, isSystem: true }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + } + + // Get custom roles (non-system) + async getCustomRoles(tenantId: string): Promise { + return this.roleRepository.find({ + where: { tenantId, isSystem: false }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + } + + // Clone a role + async cloneRole( + id: string, + tenantId: string, + newCode: string, + newName: string, + createdBy: string + ): Promise { + const sourceRole = await this.findOne(id, tenantId); + if (!sourceRole) return null; + + // Check if new code exists + const existingCode = await this.findByCode(newCode, tenantId); + if (existingCode) { + throw new Error(`Ya existe un rol con el codigo "${newCode}"`); + } + + const newRole = this.roleRepository.create({ + tenantId, + name: newName, + code: newCode, + description: sourceRole.description, + isSystem: false, // Cloned roles are never system roles + color: sourceRole.color, + permissions: sourceRole.permissions, + createdBy, + }); + + return this.roleRepository.save(newRole); + } + + async delete(id: string, tenantId: string): Promise { + const role = await this.findOne(id, tenantId); + if (!role) return false; + + // Cannot delete system roles + if (role.isSystem) { + throw new Error('No se pueden eliminar roles del sistema'); + } + + // Check if role has users + if (role.users && role.users.length > 0) { + throw new Error('No se puede eliminar un rol que tiene usuarios asignados'); + } + + const result = await this.roleRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/carta-porte/services/carta-porte.service.ts b/src/modules/carta-porte/services/carta-porte.service.ts new file mode 100644 index 0000000..06320e2 --- /dev/null +++ b/src/modules/carta-porte/services/carta-porte.service.ts @@ -0,0 +1,446 @@ +import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm'; +import { + CartaPorte, + EstadoCartaPorte, + TipoCfdiCartaPorte, + UbicacionCartaPorte, + MercanciaCartaPorte, + FiguraTransporte, + AutotransporteCartaPorte, +} from '../entities'; + +export interface CartaPorteSearchParams { + tenantId: string; + search?: string; + estado?: EstadoCartaPorte; + estados?: EstadoCartaPorte[]; + tipoCfdi?: TipoCfdiCartaPorte; + viajeId?: string; + emisorRfc?: string; + receptorRfc?: string; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +export interface CreateCartaPorteDto { + viajeId: string; + tipoCfdi: TipoCfdiCartaPorte; + versionCartaPorte?: string; + serie?: string; + // Emisor + emisorRfc: string; + emisorNombre: string; + emisorRegimenFiscal?: string; + // Receptor + receptorRfc: string; + receptorNombre: string; + receptorUsoCfdi?: string; + receptorDomicilioFiscalCp?: string; + // Totales + subtotal?: number; + total?: number; + moneda?: string; + // Transporte internacional + transporteInternacional?: boolean; + entradaSalidaMerc?: string; + paisOrigenDestino?: string; + // Datos autotransporte + permisoSct?: string; + numPermisoSct?: string; + configVehicular?: string; + pesoBrutoTotal?: number; + unidadPeso?: string; + numTotalMercancias?: number; + // Seguros + aseguraRespCivil?: string; + polizaRespCivil?: string; + aseguraMedAmbiente?: string; + polizaMedAmbiente?: string; + aseguraCarga?: string; + polizaCarga?: string; + primaSeguro?: number; +} + +export interface UpdateCartaPorteDto extends Partial { + estado?: EstadoCartaPorte; +} + +export interface TimbrarCartaPorteDto { + uuidCfdi: string; + fechaTimbrado: Date; + xmlCfdi: string; + xmlCartaPorte: string; + pdfUrl?: string; + qrUrl?: string; +} + +export interface CancelarCartaPorteDto { + motivoCancelacion: string; + uuidSustitucion?: string; +} + +export class CartaPorteService { + constructor( + private readonly cartaPorteRepository: Repository, + private readonly ubicacionRepository: Repository, + private readonly mercanciaRepository: Repository, + private readonly figuraRepository: Repository, + private readonly autotransporteRepository: Repository, + ) {} + + async findAll(params: CartaPorteSearchParams): Promise<{ data: CartaPorte[]; total: number }> { + const { + tenantId, + search, + estado, + estados, + tipoCfdi, + viajeId, + emisorRfc, + receptorRfc, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (tipoCfdi) { + baseWhere.tipoCfdi = tipoCfdi; + } + + if (viajeId) { + baseWhere.viajeId = viajeId; + } + + if (emisorRfc) { + baseWhere.emisorRfc = emisorRfc; + } + + if (receptorRfc) { + baseWhere.receptorRfc = receptorRfc; + } + + if (fechaDesde && fechaHasta) { + baseWhere.createdAt = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, folio: ILike(`%${search}%`) }, + { ...baseWhere, serie: ILike(`%${search}%`) }, + { ...baseWhere, emisorNombre: ILike(`%${search}%`) }, + { ...baseWhere, receptorNombre: ILike(`%${search}%`) }, + { ...baseWhere, receptorRfc: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.cartaPorteRepository.findAndCount({ + where, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + take: limit, + skip: offset, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.cartaPorteRepository.findOne({ + where: { id, tenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }); + } + + async findByViaje(viajeId: string, tenantId: string): Promise { + return this.cartaPorteRepository.find({ + where: { viajeId, tenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + order: { createdAt: 'DESC' }, + }); + } + + async findByUuid(uuidCfdi: string, tenantId: string): Promise { + return this.cartaPorteRepository.findOne({ + where: { uuidCfdi, tenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + }); + } + + async create(tenantId: string, dto: CreateCartaPorteDto, createdBy: string): Promise { + const cartaPorte = this.cartaPorteRepository.create({ + ...dto, + tenantId, + estado: EstadoCartaPorte.BORRADOR, + createdById: createdBy, + }); + + return this.cartaPorteRepository.save(cartaPorte); + } + + async update( + id: string, + tenantId: string, + dto: UpdateCartaPorteDto, + updatedBy: string + ): Promise { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) return null; + + // Cannot update if already timbrada or cancelada + if (cartaPorte.estado === EstadoCartaPorte.TIMBRADA) { + throw new Error('No se puede modificar una Carta Porte ya timbrada'); + } + + if (cartaPorte.estado === EstadoCartaPorte.CANCELADA) { + throw new Error('No se puede modificar una Carta Porte cancelada'); + } + + Object.assign(cartaPorte, dto); + return this.cartaPorteRepository.save(cartaPorte); + } + + async validar(id: string, tenantId: string): Promise<{ valid: boolean; errors: string[] }> { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) { + return { valid: false, errors: ['Carta Porte no encontrada'] }; + } + + const errors: string[] = []; + + // Validaciones requeridas para Carta Porte 3.1 + if (!cartaPorte.emisorRfc) errors.push('RFC del emisor es requerido'); + if (!cartaPorte.emisorNombre) errors.push('Nombre del emisor es requerido'); + if (!cartaPorte.receptorRfc) errors.push('RFC del receptor es requerido'); + if (!cartaPorte.receptorNombre) errors.push('Nombre del receptor es requerido'); + + // Validar ubicaciones (minimo origen y destino) + if (!cartaPorte.ubicaciones || cartaPorte.ubicaciones.length < 2) { + errors.push('Se requieren al menos 2 ubicaciones (origen y destino)'); + } + + // Validar mercancias + if (!cartaPorte.mercancias || cartaPorte.mercancias.length === 0) { + errors.push('Se requiere al menos una mercancia'); + } + + // Validar figuras de transporte + if (!cartaPorte.figuras || cartaPorte.figuras.length === 0) { + errors.push('Se requiere al menos una figura de transporte (operador)'); + } + + // Validar autotransporte + if (!cartaPorte.autotransporte || cartaPorte.autotransporte.length === 0) { + errors.push('Se requiere informacion de autotransporte'); + } + + // Validar datos de transporte federal + if (!cartaPorte.permisoSct) errors.push('Tipo de permiso SCT es requerido'); + if (!cartaPorte.numPermisoSct) errors.push('Numero de permiso SCT es requerido'); + if (!cartaPorte.configVehicular) errors.push('Configuracion vehicular es requerida'); + + // Si es CFDI de ingreso, validar totales + if (cartaPorte.tipoCfdi === TipoCfdiCartaPorte.INGRESO) { + if (cartaPorte.total === null || cartaPorte.total === undefined) { + errors.push('Total del CFDI es requerido para tipo Ingreso'); + } + } + + return { valid: errors.length === 0, errors }; + } + + async marcarValidada(id: string, tenantId: string): Promise { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) return null; + + const validation = await this.validar(id, tenantId); + if (!validation.valid) { + throw new Error(`Carta Porte no valida: ${validation.errors.join(', ')}`); + } + + cartaPorte.estado = EstadoCartaPorte.VALIDADA; + return this.cartaPorteRepository.save(cartaPorte); + } + + async timbrar( + id: string, + tenantId: string, + dto: TimbrarCartaPorteDto + ): Promise { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) return null; + + if (cartaPorte.estado !== EstadoCartaPorte.VALIDADA) { + throw new Error('La Carta Porte debe estar validada antes de timbrar'); + } + + Object.assign(cartaPorte, { + ...dto, + estado: EstadoCartaPorte.TIMBRADA, + }); + + return this.cartaPorteRepository.save(cartaPorte); + } + + async cancelar( + id: string, + tenantId: string, + dto: CancelarCartaPorteDto + ): Promise { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) return null; + + if (cartaPorte.estado !== EstadoCartaPorte.TIMBRADA) { + throw new Error('Solo se pueden cancelar Cartas Porte timbradas'); + } + + Object.assign(cartaPorte, { + estado: EstadoCartaPorte.CANCELADA, + fechaCancelacion: new Date(), + motivoCancelacion: dto.motivoCancelacion, + uuidSustitucion: dto.uuidSustitucion, + }); + + return this.cartaPorteRepository.save(cartaPorte); + } + + // Ubicaciones + async addUbicacion( + cartaPorteId: string, + tenantId: string, + ubicacionData: Partial + ): Promise { + const cartaPorte = await this.findOne(cartaPorteId, tenantId); + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error('Solo se pueden agregar ubicaciones en estado BORRADOR'); + } + + const ubicacion = this.ubicacionRepository.create({ + ...ubicacionData, + cartaPorteId, + }); + + return this.ubicacionRepository.save(ubicacion); + } + + // Mercancias + async addMercancia( + cartaPorteId: string, + tenantId: string, + mercanciaData: Partial + ): Promise { + const cartaPorte = await this.findOne(cartaPorteId, tenantId); + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error('Solo se pueden agregar mercancias en estado BORRADOR'); + } + + const mercancia = this.mercanciaRepository.create({ + ...mercanciaData, + cartaPorteId, + }); + + return this.mercanciaRepository.save(mercancia); + } + + // Figuras de transporte + async addFigura( + cartaPorteId: string, + tenantId: string, + figuraData: Partial + ): Promise { + const cartaPorte = await this.findOne(cartaPorteId, tenantId); + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error('Solo se pueden agregar figuras en estado BORRADOR'); + } + + const figura = this.figuraRepository.create({ + ...figuraData, + cartaPorteId, + }); + + return this.figuraRepository.save(figura); + } + + // Autotransporte + async addAutotransporte( + cartaPorteId: string, + tenantId: string, + autoData: Partial + ): Promise { + const cartaPorte = await this.findOne(cartaPorteId, tenantId); + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error('Solo se puede agregar autotransporte en estado BORRADOR'); + } + + const auto = this.autotransporteRepository.create({ + ...autoData, + cartaPorteId, + }); + + return this.autotransporteRepository.save(auto); + } + + // Cartas porte pendientes de timbrar + async getPendientesTimbrar(tenantId: string): Promise { + return this.cartaPorteRepository.find({ + where: { + tenantId, + estado: In([EstadoCartaPorte.BORRADOR, EstadoCartaPorte.VALIDADA]), + }, + order: { createdAt: 'ASC' }, + }); + } + + // Cartas porte por viaje (para reportes) + async getCartasPorteViaje(viajeId: string, tenantId: string): Promise { + return this.cartaPorteRepository.find({ + where: { viajeId, tenantId }, + relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'], + order: { createdAt: 'DESC' }, + }); + } + + async delete(id: string, tenantId: string): Promise { + const cartaPorte = await this.findOne(id, tenantId); + if (!cartaPorte) return false; + + // Only allow deletion of BORRADOR + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error('Solo se pueden eliminar Cartas Porte en estado BORRADOR'); + } + + const result = await this.cartaPorteRepository.delete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/carta-porte/services/index.ts b/src/modules/carta-porte/services/index.ts index fdcab2a..601392e 100644 --- a/src/modules/carta-porte/services/index.ts +++ b/src/modules/carta-porte/services/index.ts @@ -1,8 +1,10 @@ /** * Carta Porte Services + * CFDI con Complemento Carta Porte 3.1 */ -// TODO: Implement services -// - carta-porte.service.ts -// - carta-porte-validator.service.ts -// - pac-integration.service.ts -// - cfdi-generator.service.ts +export * from './carta-porte.service'; + +// TODO: Implement additional services +// - carta-porte-validator.service.ts (validacion SAT detallada) +// - pac-integration.service.ts (integracion con PAC) +// - cfdi-generator.service.ts (generacion XML CFDI) diff --git a/src/modules/tarifas-transporte/services/index.ts b/src/modules/tarifas-transporte/services/index.ts index acafc29..b9541ac 100644 --- a/src/modules/tarifas-transporte/services/index.ts +++ b/src/modules/tarifas-transporte/services/index.ts @@ -1,7 +1,11 @@ /** - * Tarifas Services + * Tarifas de Transporte Services + * Cotizacion, lanes, tarifas */ -// TODO: Implement services -// - tarifas.service.ts -// - recargos.service.ts -// - cotizador.service.ts +export * from './tarifas.service'; +export * from './lanes.service'; + +// TODO: Implement additional services +// - recargos.service.ts (RecargoCatalogo) +// - fuel-surcharge.service.ts (FuelSurcharge) +// - cotizador.service.ts (motor de cotizacion avanzado) diff --git a/src/modules/tarifas-transporte/services/lanes.service.ts b/src/modules/tarifas-transporte/services/lanes.service.ts new file mode 100644 index 0000000..6a4a41b --- /dev/null +++ b/src/modules/tarifas-transporte/services/lanes.service.ts @@ -0,0 +1,319 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Lane } from '../entities'; + +export interface LaneSearchParams { + tenantId: string; + search?: string; + origenCiudad?: string; + origenEstado?: string; + destinoCiudad?: string; + destinoEstado?: string; + activo?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateLaneDto { + codigo: string; + nombre: string; + origenCiudad: string; + origenEstado: string; + origenCodigoPostal?: string; + destinoCiudad: string; + destinoEstado: string; + destinoCodigoPostal?: string; + distanciaKm?: number; + tiempoEstimadoHoras?: number; +} + +export interface UpdateLaneDto extends Partial { + activo?: boolean; +} + +export class LanesService { + constructor(private readonly laneRepository: Repository) {} + + async findAll(params: LaneSearchParams): Promise<{ data: Lane[]; total: number }> { + const { + tenantId, + search, + origenCiudad, + origenEstado, + destinoCiudad, + destinoEstado, + activo, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (activo !== undefined) { + baseWhere.activo = activo; + } + + if (origenCiudad) { + baseWhere.origenCiudad = ILike(`%${origenCiudad}%`); + } + + if (origenEstado) { + baseWhere.origenEstado = ILike(`%${origenEstado}%`); + } + + if (destinoCiudad) { + baseWhere.destinoCiudad = ILike(`%${destinoCiudad}%`); + } + + if (destinoEstado) { + baseWhere.destinoEstado = ILike(`%${destinoEstado}%`); + } + + if (search) { + where.push( + { ...baseWhere, codigo: ILike(`%${search}%`) }, + { ...baseWhere, nombre: ILike(`%${search}%`) }, + { ...baseWhere, origenCiudad: ILike(`%${search}%`) }, + { ...baseWhere, destinoCiudad: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.laneRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { activo: 'DESC', codigo: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.laneRepository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCodigo(codigo: string, tenantId: string): Promise { + return this.laneRepository.findOne({ + where: { codigo, tenantId }, + }); + } + + async findByOrigenDestino( + origenCiudad: string, + destinoCiudad: string, + tenantId: string + ): Promise { + return this.laneRepository.findOne({ + where: { + origenCiudad: ILike(origenCiudad), + destinoCiudad: ILike(destinoCiudad), + tenantId, + activo: true, + }, + }); + } + + async create(tenantId: string, dto: CreateLaneDto, createdBy: string): Promise { + // Check if codigo already exists + const existingCodigo = await this.findByCodigo(dto.codigo, tenantId); + if (existingCodigo) { + throw new Error(`Ya existe una lane con el codigo "${dto.codigo}"`); + } + + const lane = this.laneRepository.create({ + ...dto, + tenantId, + activo: true, + createdById: createdBy, + }); + + return this.laneRepository.save(lane); + } + + async update( + id: string, + tenantId: string, + dto: UpdateLaneDto, + updatedBy: string + ): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return null; + + // Check for codigo uniqueness if changing codigo + if (dto.codigo && dto.codigo !== lane.codigo) { + const existingCodigo = await this.findByCodigo(dto.codigo, tenantId); + if (existingCodigo && existingCodigo.id !== id) { + throw new Error(`Ya existe una lane con el codigo "${dto.codigo}"`); + } + } + + Object.assign(lane, dto); + return this.laneRepository.save(lane); + } + + async activar(id: string, tenantId: string): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return null; + + lane.activo = true; + return this.laneRepository.save(lane); + } + + async desactivar(id: string, tenantId: string): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return null; + + lane.activo = false; + return this.laneRepository.save(lane); + } + + // Get lanes by origin state + async getLanesByOrigenEstado(origenEstado: string, tenantId: string): Promise { + return this.laneRepository.find({ + where: { origenEstado: ILike(`%${origenEstado}%`), tenantId, activo: true }, + order: { origenCiudad: 'ASC', destinoCiudad: 'ASC' }, + }); + } + + // Get lanes by destination state + async getLanesByDestinoEstado(destinoEstado: string, tenantId: string): Promise { + return this.laneRepository.find({ + where: { destinoEstado: ILike(`%${destinoEstado}%`), tenantId, activo: true }, + order: { origenCiudad: 'ASC', destinoCiudad: 'ASC' }, + }); + } + + // Get all unique origin cities + async getOrigenCiudades(tenantId: string): Promise { + const lanes = await this.laneRepository + .createQueryBuilder('l') + .select('DISTINCT l.origen_ciudad', 'ciudad') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.activo = true') + .orderBy('l.origen_ciudad', 'ASC') + .getRawMany(); + + return lanes.map(l => l.ciudad); + } + + // Get all unique destination cities + async getDestinoCiudades(tenantId: string): Promise { + const lanes = await this.laneRepository + .createQueryBuilder('l') + .select('DISTINCT l.destino_ciudad', 'ciudad') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.activo = true') + .orderBy('l.destino_ciudad', 'ASC') + .getRawMany(); + + return lanes.map(l => l.ciudad); + } + + // Get all unique origin states + async getOrigenEstados(tenantId: string): Promise { + const lanes = await this.laneRepository + .createQueryBuilder('l') + .select('DISTINCT l.origen_estado', 'estado') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.activo = true') + .orderBy('l.origen_estado', 'ASC') + .getRawMany(); + + return lanes.map(l => l.estado); + } + + // Get all unique destination states + async getDestinoEstados(tenantId: string): Promise { + const lanes = await this.laneRepository + .createQueryBuilder('l') + .select('DISTINCT l.destino_estado', 'estado') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.activo = true') + .orderBy('l.destino_estado', 'ASC') + .getRawMany(); + + return lanes.map(l => l.estado); + } + + // Search for lane or create if not exists + async findOrCreate( + tenantId: string, + dto: CreateLaneDto, + createdBy: string + ): Promise<{ lane: Lane; created: boolean }> { + // Try to find by origen-destino first + let lane = await this.findByOrigenDestino(dto.origenCiudad, dto.destinoCiudad, tenantId); + + if (lane) { + return { lane, created: false }; + } + + // Try to find by codigo + lane = await this.findByCodigo(dto.codigo, tenantId); + if (lane) { + return { lane, created: false }; + } + + // Create new lane + const newLane = await this.create(tenantId, dto, createdBy); + return { lane: newLane, created: true }; + } + + // Calculate reverse lane (destino -> origen) + async getLaneInversa(id: string, tenantId: string): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return null; + + return this.findByOrigenDestino(lane.destinoCiudad, lane.origenCiudad, tenantId); + } + + // Create reverse lane automatically + async crearLaneInversa(id: string, tenantId: string, createdBy: string): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return null; + + // Check if reverse already exists + const existing = await this.findByOrigenDestino( + lane.destinoCiudad, + lane.origenCiudad, + tenantId + ); + if (existing) { + return existing; + } + + // Generate reverse codigo + const reverseCodigo = `${lane.codigo}-REV`; + const reverseNombre = `${lane.destinoCiudad} - ${lane.origenCiudad}`; + + return this.create( + tenantId, + { + codigo: reverseCodigo, + nombre: reverseNombre, + origenCiudad: lane.destinoCiudad, + origenEstado: lane.destinoEstado, + origenCodigoPostal: lane.destinoCodigoPostal ?? undefined, + destinoCiudad: lane.origenCiudad, + destinoEstado: lane.origenEstado, + destinoCodigoPostal: lane.origenCodigoPostal ?? undefined, + distanciaKm: lane.distanciaKm ?? undefined, + tiempoEstimadoHoras: lane.tiempoEstimadoHoras ?? undefined, + }, + createdBy + ); + } + + async delete(id: string, tenantId: string): Promise { + const lane = await this.findOne(id, tenantId); + if (!lane) return false; + + // Note: Should check if lane is used in any tarifas before deleting + const result = await this.laneRepository.delete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/tarifas-transporte/services/tarifas.service.ts b/src/modules/tarifas-transporte/services/tarifas.service.ts new file mode 100644 index 0000000..033efd1 --- /dev/null +++ b/src/modules/tarifas-transporte/services/tarifas.service.ts @@ -0,0 +1,442 @@ +import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm'; +import { Tarifa, TipoTarifa, Lane } from '../entities'; + +export interface TarifaSearchParams { + tenantId: string; + search?: string; + clienteId?: string; + laneId?: string; + tipoTarifa?: TipoTarifa; + activa?: boolean; + vigente?: boolean; + moneda?: string; + limit?: number; + offset?: number; +} + +export interface CreateTarifaDto { + codigo: string; + nombre: string; + descripcion?: string; + clienteId?: string; + laneId?: string; + modalidadServicio?: string; + tipoEquipo?: string; + tipoTarifa: TipoTarifa; + tarifaBase: number; + tarifaKm?: number; + tarifaTonelada?: number; + tarifaM3?: number; + tarifaPallet?: number; + tarifaHora?: number; + minimoFacturar?: number; + pesoMinimoKg?: number; + moneda?: string; + fechaInicio: Date; + fechaFin?: Date; +} + +export interface UpdateTarifaDto extends Partial { + activa?: boolean; +} + +export interface CotizarParams { + km?: number; + toneladas?: number; + m3?: number; + pallets?: number; + horas?: number; +} + +export interface CotizacionResult { + tarifa: Tarifa; + montoCalculado: number; + desglose: { + tarifaBase: number; + montoVariable: number; + minimoAplicado: boolean; + }; +} + +export class TarifasService { + constructor( + private readonly tarifaRepository: Repository, + private readonly laneRepository: Repository, + ) {} + + async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> { + const { + tenantId, + search, + clienteId, + laneId, + tipoTarifa, + activa, + vigente, + moneda, + limit = 50, + offset = 0, + } = params; + + const qb = this.tarifaRepository.createQueryBuilder('t'); + + qb.where('t.tenant_id = :tenantId', { tenantId }); + + if (clienteId) { + qb.andWhere('t.cliente_id = :clienteId', { clienteId }); + } + + if (laneId) { + qb.andWhere('t.lane_id = :laneId', { laneId }); + } + + if (tipoTarifa) { + qb.andWhere('t.tipo_tarifa = :tipoTarifa', { tipoTarifa }); + } + + if (activa !== undefined) { + qb.andWhere('t.activa = :activa', { activa }); + } + + if (vigente) { + const hoy = new Date(); + qb.andWhere('t.activa = true'); + qb.andWhere('t.fecha_inicio <= :hoy', { hoy }); + qb.andWhere('(t.fecha_fin IS NULL OR t.fecha_fin >= :hoy)', { hoy }); + } + + if (moneda) { + qb.andWhere('t.moneda = :moneda', { moneda }); + } + + if (search) { + qb.andWhere( + '(t.codigo ILIKE :search OR t.nombre ILIKE :search OR t.descripcion ILIKE :search)', + { search: `%${search}%` } + ); + } + + qb.leftJoinAndSelect('t.lane', 'lane'); + qb.orderBy('t.activa', 'DESC'); + qb.addOrderBy('t.fecha_inicio', 'DESC'); + qb.take(limit); + qb.skip(offset); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.tarifaRepository.findOne({ + where: { id, tenantId }, + relations: ['lane'], + }); + } + + async findByCodigo(codigo: string, tenantId: string): Promise { + return this.tarifaRepository.findOne({ + where: { codigo, tenantId }, + relations: ['lane'], + }); + } + + async create(tenantId: string, dto: CreateTarifaDto, createdBy: string): Promise { + // Check if codigo already exists + const existingCodigo = await this.findByCodigo(dto.codigo, tenantId); + if (existingCodigo) { + throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`); + } + + // Validate lane if provided + if (dto.laneId) { + const lane = await this.laneRepository.findOne({ + where: { id: dto.laneId, tenantId }, + }); + if (!lane) { + throw new Error('Lane no encontrada'); + } + } + + const tarifa = this.tarifaRepository.create({ + ...dto, + tenantId, + activa: true, + createdById: createdBy, + }); + + return this.tarifaRepository.save(tarifa); + } + + async update( + id: string, + tenantId: string, + dto: UpdateTarifaDto, + updatedBy: string + ): Promise { + const tarifa = await this.findOne(id, tenantId); + if (!tarifa) return null; + + // Check for codigo uniqueness if changing codigo + if (dto.codigo && dto.codigo !== tarifa.codigo) { + const existingCodigo = await this.findByCodigo(dto.codigo, tenantId); + if (existingCodigo && existingCodigo.id !== id) { + throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`); + } + } + + // Validate lane if provided + if (dto.laneId && dto.laneId !== tarifa.laneId) { + const lane = await this.laneRepository.findOne({ + where: { id: dto.laneId, tenantId }, + }); + if (!lane) { + throw new Error('Lane no encontrada'); + } + } + + Object.assign(tarifa, dto); + return this.tarifaRepository.save(tarifa); + } + + async activar(id: string, tenantId: string): Promise { + const tarifa = await this.findOne(id, tenantId); + if (!tarifa) return null; + + tarifa.activa = true; + return this.tarifaRepository.save(tarifa); + } + + async desactivar(id: string, tenantId: string): Promise { + const tarifa = await this.findOne(id, tenantId); + if (!tarifa) return null; + + tarifa.activa = false; + return this.tarifaRepository.save(tarifa); + } + + // Get best tariff for a client/lane combination + async getTarifaAplicable( + tenantId: string, + params: { + clienteId?: string; + laneId?: string; + tipoEquipo?: string; + modalidadServicio?: string; + } + ): Promise { + const hoy = new Date(); + const qb = this.tarifaRepository.createQueryBuilder('t'); + + qb.where('t.tenant_id = :tenantId', { tenantId }); + qb.andWhere('t.activa = true'); + qb.andWhere('t.fecha_inicio <= :hoy', { hoy }); + qb.andWhere('(t.fecha_fin IS NULL OR t.fecha_fin >= :hoy)', { hoy }); + + // Priority: specific client tariff > lane tariff > general tariff + if (params.clienteId && params.laneId) { + // First try client + lane specific + const specific = await qb + .clone() + .andWhere('t.cliente_id = :clienteId', { clienteId: params.clienteId }) + .andWhere('t.lane_id = :laneId', { laneId: params.laneId }) + .getOne(); + if (specific) return specific; + } + + if (params.clienteId) { + // Try client specific (any lane) + const clientTarifa = await qb + .clone() + .andWhere('t.cliente_id = :clienteId', { clienteId: params.clienteId }) + .andWhere('t.lane_id IS NULL') + .getOne(); + if (clientTarifa) return clientTarifa; + } + + if (params.laneId) { + // Try lane specific (any client) + const laneTarifa = await qb + .clone() + .andWhere('t.cliente_id IS NULL') + .andWhere('t.lane_id = :laneId', { laneId: params.laneId }) + .getOne(); + if (laneTarifa) return laneTarifa; + } + + // Try general tariff with matching equipment/modalidad + if (params.tipoEquipo || params.modalidadServicio) { + const generalQb = qb.clone() + .andWhere('t.cliente_id IS NULL') + .andWhere('t.lane_id IS NULL'); + + if (params.tipoEquipo) { + generalQb.andWhere('t.tipo_equipo = :tipoEquipo', { tipoEquipo: params.tipoEquipo }); + } + if (params.modalidadServicio) { + generalQb.andWhere('t.modalidad_servicio = :modalidadServicio', { + modalidadServicio: params.modalidadServicio, + }); + } + + const generalTarifa = await generalQb.getOne(); + if (generalTarifa) return generalTarifa; + } + + // Fallback to any general tariff + return qb + .clone() + .andWhere('t.cliente_id IS NULL') + .andWhere('t.lane_id IS NULL') + .orderBy('t.created_at', 'DESC') + .getOne(); + } + + // Calculate quote using a tariff + async cotizar(tarifaId: string, tenantId: string, params: CotizarParams): Promise { + const tarifa = await this.findOne(tarifaId, tenantId); + if (!tarifa) { + throw new Error('Tarifa no encontrada'); + } + + if (!tarifa.vigente) { + throw new Error('La tarifa no esta vigente'); + } + + let montoVariable = 0; + + switch (tarifa.tipoTarifa) { + case TipoTarifa.POR_KM: + montoVariable = (params.km || 0) * (tarifa.tarifaKm || 0); + break; + case TipoTarifa.POR_TONELADA: + montoVariable = (params.toneladas || 0) * (tarifa.tarifaTonelada || 0); + break; + case TipoTarifa.POR_M3: + montoVariable = (params.m3 || 0) * (tarifa.tarifaM3 || 0); + break; + case TipoTarifa.POR_PALLET: + montoVariable = (params.pallets || 0) * (tarifa.tarifaPallet || 0); + break; + case TipoTarifa.POR_HORA: + montoVariable = (params.horas || 0) * (tarifa.tarifaHora || 0); + break; + case TipoTarifa.MIXTA: + montoVariable += (params.km || 0) * (tarifa.tarifaKm || 0); + montoVariable += (params.toneladas || 0) * (tarifa.tarifaTonelada || 0); + break; + case TipoTarifa.POR_VIAJE: + default: + montoVariable = 0; + break; + } + + let montoCalculado = Number(tarifa.tarifaBase) + montoVariable; + let minimoAplicado = false; + + if (tarifa.minimoFacturar && montoCalculado < Number(tarifa.minimoFacturar)) { + montoCalculado = Number(tarifa.minimoFacturar); + minimoAplicado = true; + } + + return { + tarifa, + montoCalculado, + desglose: { + tarifaBase: Number(tarifa.tarifaBase), + montoVariable, + minimoAplicado, + }, + }; + } + + // Get tarifas by cliente + async getTarifasCliente(clienteId: string, tenantId: string): Promise { + return this.tarifaRepository.find({ + where: { clienteId, tenantId, activa: true }, + relations: ['lane'], + order: { fechaInicio: 'DESC' }, + }); + } + + // Get tarifas by lane + async getTarifasLane(laneId: string, tenantId: string): Promise { + return this.tarifaRepository.find({ + where: { laneId, tenantId, activa: true }, + relations: ['lane'], + order: { fechaInicio: 'DESC' }, + }); + } + + // Get tarifas expiring soon + async getTarifasPorVencer(tenantId: string, diasAntelacion: number = 30): Promise { + const hoy = new Date(); + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + diasAntelacion); + + return this.tarifaRepository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.activa = true') + .andWhere('t.fecha_fin IS NOT NULL') + .andWhere('t.fecha_fin > :hoy', { hoy }) + .andWhere('t.fecha_fin <= :fechaLimite', { fechaLimite }) + .leftJoinAndSelect('t.lane', 'lane') + .orderBy('t.fecha_fin', 'ASC') + .getMany(); + } + + // Clone a tariff with new validity period + async clonarTarifa( + id: string, + tenantId: string, + nuevoCodigo: string, + nuevaFechaInicio: Date, + nuevaFechaFin?: Date, + createdBy?: string + ): Promise { + const sourceTarifa = await this.findOne(id, tenantId); + if (!sourceTarifa) return null; + + // Check if new codigo exists + const existingCodigo = await this.findByCodigo(nuevoCodigo, tenantId); + if (existingCodigo) { + throw new Error(`Ya existe una tarifa con el codigo "${nuevoCodigo}"`); + } + + const newTarifa = this.tarifaRepository.create({ + tenantId, + codigo: nuevoCodigo, + nombre: sourceTarifa.nombre, + descripcion: sourceTarifa.descripcion, + clienteId: sourceTarifa.clienteId, + laneId: sourceTarifa.laneId, + modalidadServicio: sourceTarifa.modalidadServicio, + tipoEquipo: sourceTarifa.tipoEquipo, + tipoTarifa: sourceTarifa.tipoTarifa, + tarifaBase: sourceTarifa.tarifaBase, + tarifaKm: sourceTarifa.tarifaKm, + tarifaTonelada: sourceTarifa.tarifaTonelada, + tarifaM3: sourceTarifa.tarifaM3, + tarifaPallet: sourceTarifa.tarifaPallet, + tarifaHora: sourceTarifa.tarifaHora, + minimoFacturar: sourceTarifa.minimoFacturar, + pesoMinimoKg: sourceTarifa.pesoMinimoKg, + moneda: sourceTarifa.moneda, + fechaInicio: nuevaFechaInicio, + fechaFin: nuevaFechaFin ?? null, + activa: true, + createdById: createdBy ?? sourceTarifa.createdById, + }); + + return this.tarifaRepository.save(newTarifa); + } + + async delete(id: string, tenantId: string): Promise { + const tarifa = await this.findOne(id, tenantId); + if (!tarifa) return false; + + const result = await this.tarifaRepository.delete(id); + return (result.affected ?? 0) > 0; + } +}