""" Capa de servicio para lógica de negocio de clientes y proveedores """ import logging from typing import List, Optional, Tuple, Dict, Any from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload from .models import ClientOrProviderEnum from .dto import ( ClientProviderBasicDTO, ClientProviderCreateDTO, ClientProviderListDTO, ClientProviderResponseDTO, ClientProviderUpdateDTO, ) from .models import ClientProvider, ClientProviderAddress, ClientProviderPrograms logger = logging.getLogger(__name__) class ClientProviderService: """Servicio para gestión de clientes y proveedores""" def __init__(self, db: Session): self.db = db # Métodos para TenantCRUDRoutes @staticmethod def get_all( db: Session, tenant_id: int, company_id: Optional[int], skip: int = 0, limit: int = 50, filters: Optional[Dict[str, Any]] = None, sort_by: Optional[str] = None, sort_order: Optional[str] = "asc", ) -> Tuple[List[ClientProvider], int]: """Get all clients/providers for a tenant with pagination""" query = db.query(ClientProvider).filter(ClientProvider.tenant_id == tenant_id) if company_id is not None: query = query.filter(ClientProvider.company_id == company_id) # Apply filters if provided if filters: if filters.get("search"): search_pattern = f"%{filters['search']}%" query = query.filter( or_( ClientProvider.name.ilike(search_pattern), ClientProvider.short_name.ilike(search_pattern), ClientProvider.rfc.ilike(search_pattern), ) ) if filters.get("client_or_provider"): query = query.filter( or_( ClientProvider.client_or_provider == filters["client_or_provider"], ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH ) ) if filters.get("status"): enabled = 1 if filters["status"] == "enabled" else 0 query = query.filter(ClientProvider.is_active == enabled) # Apply sorting if sort_by: column = getattr(ClientProvider, sort_by, None) if column: if sort_order == "desc": query = query.order_by(column.desc()) else: query = query.order_by(column.asc()) else: query = query.order_by(ClientProvider.id.desc()) total = query.count() clients = ( query.options( joinedload(ClientProvider.address), joinedload(ClientProvider.programs) ) .offset(skip) .limit(limit) .all() ) return clients, total @staticmethod def get_by_id( db: Session, client_id: int, tenant_id: int, company_id: int ) -> Optional[ClientProvider]: """Get client/provider by ID""" return ( db.query(ClientProvider) .options( joinedload(ClientProvider.address), joinedload(ClientProvider.programs) ) .filter( ClientProvider.id == client_id, ClientProvider.tenant_id == tenant_id, ClientProvider.company_id == company_id, ) .first() ) @staticmethod def create( db: Session, client_data: ClientProviderCreateDTO, tenant_id: int, company_id: int, ) -> ClientProvider: """Create a new client/provider""" try: # Create main client/provider data_dict = client_data.model_dump(exclude={"address", "programs"}) db_client = ClientProvider( **data_dict, tenant_id=tenant_id, company_id=company_id ) db.add(db_client) db.flush() # Create address if provided if client_data.address: db_address = ClientProviderAddress( tenant_id=tenant_id, company_id=company_id, client_id=db_client.id, **client_data.address.model_dump(exclude_unset=True), ) db.add(db_address) # Create programs if provided if client_data.programs: db_programs = ClientProviderPrograms( tenant_id=tenant_id, company_id=company_id, client_id=db_client.id, **client_data.programs.model_dump(exclude_unset=True), ) db.add(db_programs) db.commit() db.refresh(db_client) return db_client except IntegrityError as e: db.rollback() logger.error(f"IntegrityError creating client/provider: {str(e)}") raise HTTPException( status_code=400, detail="Client/Provider already exists" ) except Exception as e: db.rollback() logger.error(f"Error creating client/provider: {str(e)}") raise HTTPException( status_code=500, detail="Error creating client/provider" ) @staticmethod def update( db: Session, client_id: int, tenant_id: int, company_id: int, client_data: ClientProviderUpdateDTO, ) -> Optional[ClientProvider]: """Update a client/provider""" client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id) if not client: return None try: # Update main fields update_data = client_data.model_dump( exclude_unset=True, exclude={"address", "programs"} ) for field, value in update_data.items(): setattr(client, field, value) # Update address if client_data.address: if client.address: address_data = client_data.address.model_dump(exclude_unset=True) for field, value in address_data.items(): setattr(client.address, field, value) else: db_address = ClientProviderAddress( client_id=client.id, tenant_id=tenant_id, company_id=company_id, **client_data.address.model_dump(exclude_unset=True), ) db.add(db_address) # Update programs if client_data.programs: if client.programs: programs_data = client_data.programs.model_dump(exclude_unset=True) for field, value in programs_data.items(): setattr(client.programs, field, value) else: db_programs = ClientProviderPrograms( client_id=client.id, tenant_id=tenant_id, company_id=company_id, **client_data.programs.model_dump(exclude_unset=True), ) db.add(db_programs) db.commit() db.refresh(client) return client except Exception as e: db.rollback() logger.error(f"Error updating client/provider {client_id}: {str(e)}") raise HTTPException( status_code=500, detail="Error updating client/provider" ) @staticmethod def delete(db: Session, client_id: int, tenant_id: int, company_id: int) -> bool: """Delete a client/provider""" client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id) if not client: return False try: db.delete(client) db.commit() return True except Exception as e: db.rollback() logger.error(f"Error deleting client/provider {client_id}: {str(e)}") raise HTTPException( status_code=500, detail="Error deleting client/provider" ) # Legacy methods for custom endpoints def create_clients_and_providers_legacy( self, company_id: int, client_data: ClientProviderCreateDTO ) -> ClientProviderResponseDTO: """Legacy method for creating client/provider""" try: # Create main client/provider data_dict = client_data.model_dump(exclude={"address", "programs"}) db_client = ClientProvider(**data_dict) self.db.add(db_client) self.db.flush() # Create address if provided if client_data.address: db_address = ClientProviderAddress( company_id=company_id, client_id=db_client.id, **client_data.address.model_dump(exclude_unset=True), ) self.db.add(db_address) # Create programs if provided if client_data.programs: db_programs = ClientProviderPrograms( company_id=company_id, client_id=db_client.id, **client_data.programs.model_dump(exclude_unset=True), ) self.db.add(db_programs) self.db.commit() return self._get_client_with_relations(db_client.id) except IntegrityError as e: self.db.rollback() logger.error(f"IntegrityError creating client/provider: {str(e)}") raise HTTPException( status_code=400, detail="Client/Provider with this ID already exists" ) except HTTPException: raise except Exception as e: self.db.rollback() logger.error(f"Error creating client/provider: {str(e)}") raise HTTPException( status_code=500, detail="Error creating client/provider" ) def get_clients_and_providers( self, client_id: str ) -> Optional[ClientProviderResponseDTO]: """ Obtiene un cliente/proveedor por ID Args: client_id: ID del cliente/proveedor Returns: ClientProviderResponseDTO o None si no existe """ return self._get_client_with_relations(client_id) def _get_client_with_relations( self, client_id: str ) -> Optional[ClientProviderResponseDTO]: """Método privado para obtener cliente con relaciones""" client = ( self.db.query(ClientProvider) .options( joinedload(ClientProvider.address), joinedload(ClientProvider.programs) ) .filter(ClientProvider.client_id == client_id) .first() ) if not client: return None return ClientProviderResponseDTO.model_validate(client) def list_clients_providers( self, skip: int = 0, limit: int = 100, search: Optional[str] = None, client_or_provider: Optional[str] = None, enabled_only: bool = False, ) -> ClientProviderListDTO: """ Lista clientes/proveedores con filtros Args: skip: Número de registros a omitir limit: Número máximo de registros a retornar search: Texto de búsqueda (nombre, RFC, ID) client_or_provider: Filtrar por tipo (client=Cliente, provider=Proveedor) enabled_only: Si True, solo retorna activos Returns: ClientProviderListDTO con la lista paginada """ query = self.db.query(ClientProvider) # Aplicar filtros if search: search_pattern = f"%{search}%" query = query.filter( or_( ClientProvider.name.ilike(search_pattern), ClientProvider.short_name.ilike(search_pattern), ClientProvider.rfc.ilike(search_pattern), ClientProvider.client_id.ilike(search_pattern), ) ) if client_or_provider: query = query.filter( or_( ClientProvider.client_or_provider == client_or_provider, ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH ) ) if enabled_only: query = query.filter(ClientProvider.is_active == 1) # Contar total total = query.count() # Aplicar paginación clients = query.offset(skip).limit(limit).all() # Convertir a DTOs básicos client_dtos = [ ClientProviderBasicDTO.model_validate(client) for client in clients ] return ClientProviderListDTO( clients=client_dtos, total=total, page=(skip // limit) + 1 if limit > 0 else 1, size=len(client_dtos), ) def update_clients_and_providers( self, client_id: str, client_data: ClientProviderUpdateDTO ) -> Optional[ClientProviderResponseDTO]: """ Actualiza un cliente/proveedor Args: client_id: ID del cliente/proveedor a actualizar client_data: Datos a actualizar Returns: ClientProviderResponseDTO actualizado o None si no existe """ client = ( self.db.query(ClientProvider) .filter(ClientProvider.client_id == client_id) .first() ) if not client: return None try: # Actualizar campos del cliente principal update_data = client_data.model_dump( exclude_unset=True, exclude={"address", "programs"} ) for field, value in update_data.items(): setattr(client, field, value) # Actualizar dirección if client_data.address: address = ( self.db.query(ClientProviderAddress) .filter(ClientProviderAddress.client_id == client_id) .first() ) if address: # Actualizar dirección existente address_data = client_data.address.model_dump(exclude_unset=True) for field, value in address_data.items(): setattr(address, field, value) else: # Crear nueva dirección address = ClientProviderAddress( client_id=client_id, **client_data.address.model_dump(exclude_unset=True), ) self.db.add(address) # Actualizar programas if client_data.programs: programs = ( self.db.query(ClientProviderPrograms) .filter(ClientProviderPrograms.client_id == client_id) .first() ) if programs: # Actualizar programas existentes programs_data = client_data.programs.model_dump(exclude_unset=True) for field, value in programs_data.items(): setattr(programs, field, value) else: # Crear nuevos programas programs = ClientProviderPrograms( client_id=client_id, **client_data.programs.model_dump(exclude_unset=True), ) self.db.add(programs) self.db.commit() return self._get_client_with_relations(client_id) except Exception as e: self.db.rollback() logger.error(f"Error updating client/provider {client_id}: {str(e)}") raise HTTPException( status_code=500, detail="Error updating client/provider" ) def delete_clients_and_providers(self, client_id: str) -> bool: """ Elimina un cliente/proveedor Args: client_id: ID del cliente/proveedor a eliminar Returns: True si se eliminó, False si no existe """ client = ( self.db.query(ClientProvider) .filter(ClientProvider.client_id == client_id) .first() ) if not client: return False try: self.db.delete(client) # Las relaciones se eliminan en cascada self.db.commit() return True except Exception as e: self.db.rollback() logger.error(f"Error deleting client/provider {client_id}: {str(e)}") raise HTTPException( status_code=500, detail="Error deleting client/provider" )