""" Capa de servicio para lógica de negocio de clientes y proveedores """ from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError from sqlalchemy import or_, and_ from fastapi import HTTPException from typing import List, Optional import logging from .models import ClientProvider, ClientProviderAddress, ClientProviderPrograms from .dto import ( ClientProviderCreateDTO, ClientProviderUpdateDTO, ClientProviderResponseDTO, ClientProviderBasicDTO, ClientProviderListDTO, ClientProviderAddressDTO, ClientProviderProgramsDTO, ) logger = logging.getLogger(__name__) class ClientProviderService: """Servicio para gestión de clientes y proveedores""" def __init__(self, db: Session): self.db = db def create_client_provider( self, client_data: ClientProviderCreateDTO ) -> ClientProviderResponseDTO: """ Crea un nuevo cliente/proveedor en el sistema Args: client_data: Datos del cliente/proveedor a crear Returns: ClientProviderResponseDTO con información del cliente/proveedor creado Raises: HTTPException: Si el cliente ya existe o error en la creación """ try: # Verificar que no exista el cliente existing = ( self.db.query(ClientProvider) .filter(ClientProvider.client_id == client_data.client_id) .first() ) if existing: raise HTTPException( status_code=400, detail=f"Client with ID '{client_data.client_id}' already exists", ) # Crear cliente/proveedor principal db_client = ClientProvider( client_id=client_data.client_id, type_nat_foreign=client_data.type_nat_foreign, name=client_data.name, short_name=client_data.short_name, rfc=client_data.rfc, curp=client_data.curp, client_or_provider=client_data.client_or_provider, linking=client_data.linking, transform_subassembly=client_data.transform_subassembly, extra_information=client_data.extra_information, web_key=client_data.web_key, responsible=client_data.responsible, position=client_data.position, incoterm=client_data.incoterm, is_national_provider=client_data.is_national_provider, enabled_disabled=client_data.enabled_disabled, ) self.db.add(db_client) self.db.flush() # Para obtener el ID antes del commit # Crear dirección si se proporciona if client_data.address: db_address = ClientProviderAddress( client_id=client_data.client_id, **client_data.address.model_dump(exclude_unset=True), ) self.db.add(db_address) # Crear programas si se proporciona if client_data.programs: db_programs = ClientProviderPrograms( client_id=client_data.client_id, **client_data.programs.model_dump(exclude_unset=True), ) self.db.add(db_programs) self.db.commit() self.db.refresh(db_client) logger.info( f"Client/Provider created: {db_client.client_id} - {db_client.name}" ) return self._get_client_with_relations(client_data.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_client_provider( 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 (C=Cliente, P=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( ClientProvider.client_or_provider == client_or_provider ) if enabled_only: query = query.filter(ClientProvider.enabled_disabled == 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_client_provider( 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() logger.info(f"Client/Provider updated: {client_id}") 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_client_provider(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() logger.info(f"Client/Provider deleted: {client_id}") 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" ) def get_clients_only( self, skip: int = 0, limit: int = 100 ) -> List[ClientProviderBasicDTO]: """Obtiene solo clientes (C)""" query = self.db.query(ClientProvider).filter( ClientProvider.client_or_provider == "C" ) clients = query.offset(skip).limit(limit).all() return [ClientProviderBasicDTO.model_validate(client) for client in clients] def get_providers_only( self, skip: int = 0, limit: int = 100 ) -> List[ClientProviderBasicDTO]: """Obtiene solo proveedores (P)""" query = self.db.query(ClientProvider).filter( ClientProvider.client_or_provider == "P" ) providers = query.offset(skip).limit(limit).all() return [ ClientProviderBasicDTO.model_validate(provider) for provider in providers ] def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]: """Busca clientes/proveedores por RFC""" clients = ( self.db.query(ClientProvider) .filter(ClientProvider.rfc.ilike(f"%{rfc}%")) .all() ) return [ClientProviderBasicDTO.model_validate(client) for client in clients] def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]: """Cambia el estado habilitado/deshabilitado""" client = ( self.db.query(ClientProvider) .filter(ClientProvider.client_id == client_id) .first() ) if not client: return None # Toggle status (1 = habilitado, 0 = deshabilitado) client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0 try: self.db.commit() logger.info( f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}" ) return self._get_client_with_relations(client_id) except Exception as e: self.db.rollback() logger.error(f"Error toggling status for {client_id}: {str(e)}") raise HTTPException(status_code=500, detail="Error updating status")