""" Service layer for Pedimentos CRUD operations """ import logging import re from typing import Any, Dict, List, Optional from sqlalchemy import desc, func from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError from datetime import datetime from api.v1.modules.a76.audit_log.models import AuditLog from api.v1.modules.a76.audit_log.services.service import AuditService from core.context import get_user_context from ..dtos.pedimentos import PedimentosCreate, PedimentosUpdate from .pedimento_config_additional import PedimentoConfigAdditionalService from .pedimento_config_calculations import PedimentoConfigCalculationsService from .pedimento_config_parameters import PedimentoConfigParametersService from .pedimento_config_surcharges import PedimentoConfigSurchargesService from .pedimento_config_update_rectification import PedimentoConfigUpdateRectificationService from .pedimento_config_updates import PedimentoConfigUpdatesService from .pedimento_customs_offices import PedimentoCustomsOfficesService from .pedimento_dates import PedimentoDatesService from .pedimento_decrementables import PedimentoDecrementablesService from .pedimento_incrementables import PedimentoIncrementablesService from .pedimento_indexes import PedimentoIndexesService from .pedimento_payments import PedimentoPaymentsService from .pedimento_rectification_destination import PedimentoRectificationDestinationService from .pedimento_rectification_origin import PedimentoRectificationOriginService from .pedimento_transport_means import PedimentoTransportMeansService from .pedimento_validation import PedimentoValidationService # Crear tablas relacionadas si existen datos from ..models.pedimentos import Pedimentos from ..models.pedimento_dates import PedimentoDates from ..models.pedimento_decrementables import PedimentoDecrementables from ..models.pedimento_incrementables import PedimentoIncrementables from ..models.pedimento_indexes import PedimentoIndexes from ..models.pedimento_validation import PedimentoValidation from ..models.pedimento_customs_offices import PedimentoCustomsOffices from ..models.pedimento_payments import PedimentoPayments from ..models.pedimento_rectification_destination import PedimentoRectificationDestination from ..models.pedimento_rectification_origin import PedimentoRectificationOrigin from ..models.pedimento_transport_means import PedimentoTransportMeans from ..models.pedimento_config_additional import PedimentoConfigAdditional from ..models.pedimento_config_calculations import PedimentoConfigCalculations from ..models.pedimento_config_parameters import PedimentoConfigParameters from ..models.pedimento_config_surcharges import PedimentoConfigSurcharges from ..models.pedimento_config_update_rectification import PedimentoConfigUpdateRectification from ..models.pedimento_config_updates import PedimentoConfigUpdates from ..models.pedimento_packages import PedimentoPackages from ..models.pedimento_transport_carriers import PedimentoTransportCarriers from ..models.pedimento_seals import PedimentoSeals from ..models.pedimento_containers import PedimentoContainers from ..models.pedimento_guides import PedimentoGuides from ..models.pedimento_contributions import PedimentoContributions logger = logging.getLogger(__name__) def _ensure_create_audit_log( db: Session, *, table_name: str, record_id: str, record_data: Dict[str, Any], username: str, tenant_id: int, company_id: int ) -> None: """ Backup audit writer: inserts CREATE log only when listener did not. """ exists = ( db.query(AuditLog.spec_id) .filter( AuditLog.table_name == table_name, AuditLog.operation_type == "CREATE", AuditLog.record_id == record_id, AuditLog.tenant_id == tenant_id, AuditLog.company_id == company_id, ) .first() ) if exists: return AuditService.log_crud_operation( db=db, table_name=table_name, operation_type="CREATE", record_data=record_data, username=username, record_id=record_id, company_id=company_id, tenant_id=tenant_id, ) db.commit() def _get_current_username() -> str: try: context = get_user_context() if context: return ( context.get("preferred_username") or context.get("email") or context.get("sub") or "System" ) except Exception: pass return "System" class PedimentosService: """Service class for Pedimentos business logic""" @staticmethod def get_all( db: Session, tenant_id: int, company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, sort_by: Optional[str] = None, sort_order: Optional[str] = "asc", ) -> tuple[List[Pedimentos], int]: """ Get all pedimentos for a tenant with pagination and filters Args: db: Database session tenant_id: Tenant ID company_id: Optional Company ID skip: Number of records to skip limit: Maximum number of records to return filters: Optional filters dict Returns: Tuple of (list of pedimentos, total count) """ query = db.query(Pedimentos).filter(Pedimentos.tenant_id == tenant_id) if company_id is not None: query = query.filter(Pedimentos.company_id == company_id) if filters: if filters.get("status"): query = query.filter(Pedimentos.status == filters["status"]) if filters.get("client_id"): query = query.filter(Pedimentos.client_id == filters["client_id"]) if filters.get("year"): query = query.filter(Pedimentos.year == filters["year"]) if filters.get("pedimento"): raw_value = str(filters["pedimento"]) # Normalizar: dejar solo dígitos (ignorar guiones, espacios, etc.) normalized = re.sub(r"\D", "", raw_value) if normalized: ped_key_expr = func.concat( func.coalesce(Pedimentos.year, ""), func.coalesce(func.substr(Pedimentos.customs_office, 1, 2), ""), func.coalesce(Pedimentos.license, ""), func.coalesce(Pedimentos.pedimento_number, ""), ) query = query.filter(ped_key_expr.ilike(f"%{normalized}%")) total = query.count() # Eager load all relationships for the response schema items = ( query.options( selectinload(Pedimentos.pedimento_dates), selectinload(Pedimentos.pedimento_decrementables), selectinload(Pedimentos.pedimento_incrementables), selectinload(Pedimentos.pedimento_indexes), selectinload(Pedimentos.pedimento_validation), selectinload(Pedimentos.pedimento_customs_offices), selectinload(Pedimentos.pedimento_payments), selectinload(Pedimentos.pedimento_rectification_destination), selectinload(Pedimentos.pedimento_rectification_origin), selectinload(Pedimentos.pedimento_transport_means), selectinload(Pedimentos.pedimento_config_additional), selectinload(Pedimentos.pedimento_config_calculations), selectinload(Pedimentos.pedimento_config_parameters), selectinload(Pedimentos.pedimento_config_surcharges), selectinload(Pedimentos.pedimento_config_update_rectification), selectinload(Pedimentos.pedimento_config_updates), selectinload(Pedimentos.pedimento_packages), selectinload(Pedimentos.pedimento_transport_carriers), selectinload(Pedimentos.pedimento_guides), selectinload(Pedimentos.pedimento_contributions), selectinload(Pedimentos.pedimento_seals), selectinload(Pedimentos.pedimento_containers), ) ) # Apply sorting if sort_by: column = getattr(Pedimentos, sort_by, None) if column: if sort_order == "desc": query = query.order_by(column.desc()) else: query = query.order_by(column.asc()) else: # Default sorting query = query.order_by(desc(Pedimentos.created_at)) items = query.offset(skip).limit(limit).all() return items, total @staticmethod def get_by_id( db: Session, pedimento_id: int, tenant_id: int, company_id: int = None ) -> Optional[Pedimentos]: """ Get a pedimento by ID Args: db: Database session pedimento_id: Pedimento ID tenant_id: Tenant ID company_id: Company ID (optional for backwards compatibility) Returns: Pedimento or None if not found """ query = db.query(Pedimentos).filter( Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id ) if company_id is not None: query = query.filter(Pedimentos.company_id == company_id) # Eager load all relationships for the response schema query = query.options( selectinload(Pedimentos.pedimento_dates), selectinload(Pedimentos.pedimento_decrementables), selectinload(Pedimentos.pedimento_incrementables), selectinload(Pedimentos.pedimento_indexes), selectinload(Pedimentos.pedimento_validation), selectinload(Pedimentos.pedimento_customs_offices), selectinload(Pedimentos.pedimento_payments), selectinload(Pedimentos.pedimento_rectification_destination), selectinload(Pedimentos.pedimento_rectification_origin), selectinload(Pedimentos.pedimento_transport_means), selectinload(Pedimentos.pedimento_config_additional), selectinload(Pedimentos.pedimento_config_calculations), selectinload(Pedimentos.pedimento_config_parameters), selectinload(Pedimentos.pedimento_config_surcharges), selectinload(Pedimentos.pedimento_config_update_rectification), selectinload(Pedimentos.pedimento_config_updates), selectinload(Pedimentos.pedimento_packages), selectinload(Pedimentos.pedimento_transport_carriers), selectinload(Pedimentos.pedimento_guides), selectinload(Pedimentos.pedimento_contributions), selectinload(Pedimentos.pedimento_seals), selectinload(Pedimentos.pedimento_containers), ) return query.first() @staticmethod def create( db: Session, pedimento_data: PedimentosCreate, tenant_id: int, company_id: int ) -> Pedimentos: """ Create a new pedimento with related tables Args: db: Database session pedimento_data: Pedimento creation data tenant_id: Tenant ID company_id: Company ID Returns: Created pedimento """ try: # Check for existing pedimento with same key (Year, Aduana, Patente, Number) # This avoids IntegrityError in many cases and provides a better error message. existing = db.query(Pedimentos).filter( Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, Pedimentos.year == pedimento_data.year, Pedimentos.customs_office == pedimento_data.customs_office, Pedimentos.license == pedimento_data.license, Pedimentos.pedimento_number == pedimento_data.pedimento_number, Pedimentos.deleted_at.is_(None) ).first() if existing: raise ValueError( f"Ya existe un pedimento con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}" ) # Extraer datos de tablas relacionadas # Extraer datos de tablas relacionadas related_data = { 'pedimento_dates': pedimento_data.pedimento_dates, 'pedimento_decrementables': pedimento_data.pedimento_decrementables, 'pedimento_incrementables': pedimento_data.pedimento_incrementables, 'pedimento_indexes': pedimento_data.pedimento_indexes, 'pedimento_validation': pedimento_data.pedimento_validation, 'pedimento_customs_offices': pedimento_data.pedimento_customs_offices, 'pedimento_payments': pedimento_data.pedimento_payments, 'pedimento_rectification_destination': pedimento_data.pedimento_rectification_destination, 'pedimento_rectification_origin': pedimento_data.pedimento_rectification_origin, 'pedimento_transport_means': pedimento_data.pedimento_transport_means, 'pedimento_config_additional': pedimento_data.pedimento_config_additional, 'pedimento_config_calculations': pedimento_data.pedimento_config_calculations, 'pedimento_config_parameters': pedimento_data.pedimento_config_parameters, 'pedimento_config_surcharges': pedimento_data.pedimento_config_surcharges, 'pedimento_config_update_rectification': pedimento_data.pedimento_config_update_rectification, 'pedimento_config_updates': pedimento_data.pedimento_config_updates, 'pedimento_packages': pedimento_data.pedimento_packages, 'pedimento_transport_carriers': pedimento_data.pedimento_transport_carriers, 'pedimento_guides': pedimento_data.pedimento_guides, 'pedimento_contributions': getattr(pedimento_data, 'pedimento_contributions', None), 'pedimento_seals': pedimento_data.pedimento_seals, 'pedimento_containers': pedimento_data.pedimento_containers, } # Crear pedimento principal (excluyendo relaciones) pedimento_dict = pedimento_data.model_dump(exclude={ 'pedimento_dates', 'pedimento_decrementables', 'pedimento_incrementables', 'pedimento_indexes', 'pedimento_validation', 'pedimento_customs_offices', 'pedimento_payments', 'pedimento_rectification_destination', 'pedimento_rectification_origin', 'pedimento_transport_means', 'pedimento_config_additional', 'pedimento_config_calculations', 'pedimento_config_parameters', 'pedimento_config_surcharges', 'pedimento_config_update_rectification', 'pedimento_config_updates', 'pedimento_packages', 'pedimento_transport_carriers', 'pedimento_guides', 'pedimento_contributions', 'pedimento_seals', 'pedimento_containers' }) pedimento = Pedimentos(**pedimento_dict) pedimento.tenant_id = tenant_id pedimento.company_id = company_id db.add(pedimento) db.flush() # Flush para obtener el ID sin commit # Helper function para crear objetos relacionados (uno a uno) def create_related(model_class, data, extra_fields=None): if data or extra_fields: # Inicializar obj_dict desde data si existe, sino como dict vacío obj_dict = data.model_dump(exclude_none=True) if data else {} # Agregar campos extra si se proporcionan if extra_fields: obj_dict.update(extra_fields) # Solo crear si hay datos significativos (más que solo IDs) significant_fields = {k: v for k, v in obj_dict.items() if k not in ('pedimento_id', 'tenant_id', 'company_id') and v is not None} if not significant_fields and not extra_fields: return obj = model_class(**obj_dict) obj.pedimento_id = pedimento.id obj.tenant_id = tenant_id obj.company_id = company_id db.add(obj) # Crear PedimentoDates con capture_time automático create_related( PedimentoDates, related_data['pedimento_dates'], extra_fields={'capture_time': datetime.now().time()} ) create_related(PedimentoDecrementables, related_data['pedimento_decrementables']) create_related(PedimentoIncrementables, related_data['pedimento_incrementables']) create_related(PedimentoIndexes, related_data['pedimento_indexes']) create_related(PedimentoValidation, related_data['pedimento_validation']) create_related(PedimentoCustomsOffices, related_data['pedimento_customs_offices']) create_related(PedimentoPayments, related_data['pedimento_payments']) create_related(PedimentoRectificationDestination, related_data['pedimento_rectification_destination']) create_related(PedimentoRectificationOrigin, related_data['pedimento_rectification_origin']) create_related(PedimentoTransportMeans, related_data['pedimento_transport_means']) create_related(PedimentoConfigAdditional, related_data['pedimento_config_additional']) create_related(PedimentoConfigCalculations, related_data['pedimento_config_calculations']) create_related(PedimentoConfigParameters, related_data['pedimento_config_parameters']) create_related(PedimentoConfigSurcharges, related_data['pedimento_config_surcharges']) create_related(PedimentoConfigUpdateRectification, related_data['pedimento_config_update_rectification']) create_related(PedimentoConfigUpdates, related_data['pedimento_config_updates']) # Crear PedimentoPackages (uno a uno) create_related(PedimentoPackages, related_data['pedimento_packages']) # Crear relaciones uno a muchos def create_many(model_class, items): if not items: return for item in items: obj_dict = item.model_dump(exclude_none=True) obj = model_class(**obj_dict) obj.pedimento_id = pedimento.id obj.tenant_id = tenant_id obj.company_id = company_id db.add(obj) create_many(PedimentoTransportCarriers, related_data['pedimento_transport_carriers']) create_many(PedimentoGuides, related_data['pedimento_guides']) create_many(PedimentoContributions, related_data['pedimento_contributions']) create_many(PedimentoSeals, related_data['pedimento_seals']) create_many(PedimentoContainers, related_data['pedimento_containers']) db.commit() db.refresh(pedimento) _ensure_create_audit_log( db, table_name=pedimento.__tablename__, record_id=str(pedimento.id), record_data={c.name: getattr(pedimento, c.name) for c in Pedimentos.__table__.columns}, username=_get_current_username(), tenant_id=tenant_id, company_id=company_id, ) return pedimento except IntegrityError as e: db.rollback() # Detectar si es un error de integridad de duplicados o similar error_msg = str(e.orig).lower() # Case-insensitive check and support for both Spanish and English common error patterns is_unique_violation = any(kw in error_msg for kw in [ 'pedimentos_unique_key', 'unique constraint', 'duplicate key', 'duplicada', 'unicidad', 'ya existe' ]) if is_unique_violation: logger.warning(f"Attempted to create duplicate pedimento or common record: {e}") raise ValueError("Ya existe un pedimento o registro relacionado con estos datos. Verifica los campos únicos.") logger.error(f"Integrity error creating pedimento: {e}") raise except Exception as e: db.rollback() logger.error(f"Error creating pedimento with related data: {e}") raise @staticmethod def update( db: Session, pedimento_id: int, tenant_id: int, pedimento_data: PedimentosUpdate, company_id: int = None ) -> Optional[Pedimentos]: """ Update a pedimento and its related tables Args: db: Database session pedimento_id: Pedimento ID tenant_id: Tenant ID pedimento_data: Updated data company_id: Company ID (optional for backwards compatibility) Returns: Updated pedimento or None if not found """ pedimento = PedimentosService.get_by_id( db, pedimento_id, tenant_id, company_id) if not pedimento: return None # Ensure company_id is set from the existing record company_id = pedimento.company_id try: # Actualizar campos principales del pedimento update_data = pedimento_data.model_dump(exclude_unset=True, exclude={ 'pedimento_dates', 'pedimento_decrementables', 'pedimento_incrementables', 'pedimento_indexes', 'pedimento_validation', 'pedimento_customs_offices', 'pedimento_payments', 'pedimento_rectification_destination', 'pedimento_rectification_origin', 'pedimento_transport_means', 'pedimento_config_additional', 'pedimento_config_calculations', 'pedimento_config_parameters', 'pedimento_config_surcharges', 'pedimento_config_update_rectification', 'pedimento_config_updates', 'pedimento_packages', 'pedimento_transport_carriers', 'pedimento_guides', 'pedimento_contributions', 'pedimento_seals', 'pedimento_containers' }) for field, value in update_data.items(): setattr(pedimento, field, value) db.flush() # Helper function para actualizar o crear objetos relacionados def update_or_create_related(service_class, model_class, data_attr): # Obtener datos del payload completo (no solo exclude_unset) full_data = pedimento_data.model_dump() if data_attr not in full_data: return data = full_data[data_attr] if not data: return existing = None if service_class: existing = service_class.get_by_pedimento_id( db, pedimento_id, tenant_id, company_id) else: existing = getattr(pedimento, data_attr, None) if existing: # Actualizar existente (excluir pedimento_id, tenant_id, company_id) # Solo actualizar valores no-None para evitar sobrescribir con None for field, value in data.items(): if hasattr(existing, field) and field not in ('pedimento_id', 'tenant_id', 'company_id'): if value is not None: setattr(existing, field, value) else: # Crear nuevo obj = model_class(**data) obj.pedimento_id = pedimento_id obj.tenant_id = tenant_id obj.company_id = company_id db.add(obj) def upsert_one_to_many(model_class, data_attr): full_data = pedimento_data.model_dump() if data_attr not in full_data: return items = full_data[data_attr] if items is None: return # Reemplazar completamente la colección por simplicidad existing_items = getattr(pedimento, data_attr) if existing_items: for item in list(existing_items): db.delete(item) for item in items: obj = model_class(**item) obj.pedimento_id = pedimento_id obj.tenant_id = tenant_id obj.company_id = company_id db.add(obj) # Actualizar o crear tablas relacionadas update_or_create_related( PedimentoDatesService, PedimentoDates, 'pedimento_dates') update_or_create_related( PedimentoDecrementablesService, PedimentoDecrementables, 'pedimento_decrementables') update_or_create_related( PedimentoIncrementablesService, PedimentoIncrementables, 'pedimento_incrementables') update_or_create_related( PedimentoIndexesService, PedimentoIndexes, 'pedimento_indexes') update_or_create_related( PedimentoValidationService, PedimentoValidation, 'pedimento_validation') update_or_create_related( PedimentoCustomsOfficesService, PedimentoCustomsOffices, 'pedimento_customs_offices') update_or_create_related( PedimentoPaymentsService, PedimentoPayments, 'pedimento_payments') update_or_create_related(PedimentoRectificationDestinationService, PedimentoRectificationDestination, 'pedimento_rectification_destination') update_or_create_related(PedimentoRectificationOriginService, PedimentoRectificationOrigin, 'pedimento_rectification_origin') update_or_create_related( PedimentoTransportMeansService, PedimentoTransportMeans, 'pedimento_transport_means') update_or_create_related(PedimentoConfigAdditionalService, PedimentoConfigAdditional, 'pedimento_config_additional') update_or_create_related(PedimentoConfigCalculationsService, PedimentoConfigCalculations, 'pedimento_config_calculations') update_or_create_related(PedimentoConfigParametersService, PedimentoConfigParameters, 'pedimento_config_parameters') update_or_create_related(PedimentoConfigSurchargesService, PedimentoConfigSurcharges, 'pedimento_config_surcharges') update_or_create_related(PedimentoConfigUpdateRectificationService, PedimentoConfigUpdateRectification, 'pedimento_config_update_rectification') update_or_create_related( PedimentoConfigUpdatesService, PedimentoConfigUpdates, 'pedimento_config_updates') # Manejar nuevas relaciones update_or_create_related(None, PedimentoPackages, 'pedimento_packages') upsert_one_to_many(PedimentoTransportCarriers, 'pedimento_transport_carriers') upsert_one_to_many(PedimentoGuides, 'pedimento_guides') upsert_one_to_many(PedimentoContributions, 'pedimento_contributions') upsert_one_to_many(PedimentoSeals, 'pedimento_seals') upsert_one_to_many(PedimentoContainers, 'pedimento_containers') db.commit() db.refresh(pedimento) return pedimento except IntegrityError as e: db.rollback() # Detectar si es un error de pedimento duplicado error_msg = str(e.orig) if 'pedimentos_unique_key' in error_msg or 'duplicate key value violates unique constraint' in error_msg: logger.warning(f"Attempted to update to duplicate pedimento: {e}") raise ValueError("Ya existe otro pedimento con estos datos (Año, Aduana, Patente, Número)") logger.error(f"Integrity error updating pedimento: {e}") raise except Exception as e: db.rollback() logger.error(f"Error updating pedimento with related data: {e}") raise @staticmethod def delete(db: Session, pedimento_id: int, tenant_id: int, company_id: int = None) -> bool: """ Delete a pedimento Args: db: Database session pedimento_id: Pedimento ID tenant_id: Tenant ID company_id: Company ID (optional for backwards compatibility) Returns: True if deleted, False if not found """ pedimento = PedimentosService.get_by_id( db, pedimento_id, tenant_id, company_id) if not pedimento: return False db.delete(pedimento) db.commit() return True