import traceback from typing import Optional, List, Tuple from sqlalchemy.orm import Session from sqlalchemy import and_, func, or_ from core.exceptions import ErrorCollector, DuplicateResourceException from core.context import get_user_context from api.v1.modules.a76.audit_log.models import AuditLog from api.v1.modules.a76.audit_log.services.service import AuditService from .common.mappers import clean_dict from .imports.validators.create import validate_create as validate_create_import from .imports.validators.update import validate_update as validate_update_import from .exports.validators.create import validate_create as validate_create_export from .exports.validators.update import validate_update as validate_update_export from .common.common_validators import invoice_exists from api.v1.modules.a76.items.models import LineItem from . import models, schemas def _autofill_transport_int_ids( db: Session, logistics_target, tenant_id: int, company_id: int, ) -> None: """ Ensures invoice logistics internal int IDs are populated when catalog rows exist. String keys (carrier_id, transport_id, trailer_num) are matched to existing catalog rows for the tenant/company; no placeholder rows are created. Invalid references must be rejected by validate_common before persist. """ # Local imports to avoid circular dependencies. from api.v1.modules.a76.transportation.transporters.models import Transporter from api.v1.modules.a76.transportation.vehicles.models import Vehicle from api.v1.modules.a76.transportation.trailers.models import Trailer def _get(field: str): if isinstance(logistics_target, dict): return logistics_target.get(field) return getattr(logistics_target, field, None) def _set(field: str, value): if isinstance(logistics_target, dict): logistics_target[field] = value else: setattr(logistics_target, field, value) carrier_code = _get("carrier_id") carrier_int_id = _get("carrier_int_id") if carrier_code and carrier_int_id is None: transporter_obj = ( db.query(Transporter) .filter( Transporter.transporter_key == carrier_code, Transporter.tenant_id == tenant_id, Transporter.company_id == company_id, ) .first() ) if transporter_obj: _set("carrier_int_id", transporter_obj.transporter_id) transport_code = _get("transport_id") transport_int_id = _get("transport_int_id") if transport_code and transport_int_id is None: vehicle_obj = ( db.query(Vehicle) .filter( func.upper(Vehicle.vehicle_key) == str(transport_code).strip().upper(), Vehicle.tenant_id == tenant_id, Vehicle.company_id == company_id, ) .first() ) if vehicle_obj: _set("transport_int_id", vehicle_obj.vehicle_id) trailer_code = _get("trailer_num") trailer_int_id = _get("trailer_int_id") if trailer_code and trailer_int_id is None: trailer_obj = ( db.query(Trailer) .filter( func.upper(Trailer.trailer_number) == str(trailer_code).strip().upper(), Trailer.tenant_id == tenant_id, Trailer.company_id == company_id, ) .first() ) if trailer_obj: _set("trailer_int_id", trailer_obj.trailer_id) # Placa: si no vino valor, tomar del vehículo y si no del remolque (catálogo) lp = _get("license_plate") if lp is None or str(lp).strip() == "": veh = None tv_id = _get("transport_int_id") tv_code = _get("transport_id") if tv_id is not None: veh = ( db.query(Vehicle) .filter( Vehicle.vehicle_id == tv_id, Vehicle.tenant_id == tenant_id, Vehicle.company_id == company_id, ) .first() ) elif tv_code: veh = ( db.query(Vehicle) .filter( func.upper(Vehicle.vehicle_key) == str(tv_code).strip().upper(), Vehicle.tenant_id == tenant_id, Vehicle.company_id == company_id, ) .first() ) if veh and getattr(veh, "plate_number", None): _set("license_plate", veh.plate_number) else: tr = None tr_id = _get("trailer_int_id") tr_code = _get("trailer_num") if tr_id is not None: tr = ( db.query(Trailer) .filter( Trailer.trailer_id == tr_id, Trailer.tenant_id == tenant_id, Trailer.company_id == company_id, ) .first() ) elif tr_code: tr = ( db.query(Trailer) .filter( func.upper(Trailer.trailer_number) == str(tr_code).strip().upper(), Trailer.tenant_id == tenant_id, Trailer.company_id == company_id, ) .first() ) if tr and getattr(tr, "plate_number", None): _set("license_plate", tr.plate_number) def _get_current_username() -> str: """Helper to get current username from context or fallback to System""" try: context = get_user_context() if context: # Token usually has 'preferred_username' or 'name' or 'sub' username = ( context.get("preferred_username") or context.get("email") or context.get("sub") or "System" ) print(f"DEBUG: _get_current_username found context: {username}") return username except Exception: pass print("DEBUG: _get_current_username NO context found, using System") return "System" def _autofill_remesa_if_needed(db: Session, invoice_data, tenant_id: int, company_id: int) -> None: """ Autocalcula remesa cuando hay pedimento consolidado y remesa viene vacía. Se hace ANTES de validar para que cumpla reglas de required en validators. """ try: compliance = getattr(invoice_data, "compliance_mx", None) if not compliance: return pedimento_id = getattr(compliance, "pedimento_id", None) remesa = getattr(compliance, "remesa", None) if not pedimento_id or remesa: return # No aplicar a MEX (por consistencia con CSV import donde remesa es None para MEX) invoice_type = getattr(invoice_data, "invoice_type", None) if invoice_type == "MEX": return from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos, PedimentoType ped = ( db.query(Pedimentos) .filter( Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, ) .first() ) if not ped: return if getattr(ped, "pedimento_type", None) != PedimentoType.CONSOLIDATED: return max_rem = ( db.query(func.max(models.InvoiceComplianceMx.remesa)) .filter( models.InvoiceComplianceMx.pedimento_id == pedimento_id, models.InvoiceComplianceMx.tenant_id == tenant_id, models.InvoiceComplianceMx.company_id == company_id, ) .scalar() ) next_rem = (max_rem or 0) + 1 compliance.remesa = next_rem except Exception: # No bloquear guardado por fallo de autocalculo; validación normal aplicará. return def _ensure_create_audit_log( db: Session, *, table_name: str, record_id: str, record_data: dict, username: str, tenant_id: int, company_id: int, ) -> bool: """ Backup audit writer: writes CREATE only when missing. Returns True when a backup log row was added. """ 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 False 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, ) return True class InvoiceService: """Service for Invoice Header operations""" @staticmethod def get_by_id( db: Session, invoice_id: int, tenant_id: int, company_id: int ) -> Optional[models.InvoiceHeader]: """Get an invoice by ID with tenant/company validation""" return ( db.query(models.InvoiceHeader) .filter( models.InvoiceHeader.id == invoice_id, models.InvoiceHeader.tenant_id == tenant_id, models.InvoiceHeader.company_id == company_id, ) .first() ) @staticmethod def get_all( db: Session, tenant_id: int, company_id: int, skip: int = 0, limit: int = 100, filters: Optional[dict] = None, sort_by: Optional[str] = None, sort_order: Optional[str] = "asc", ) -> Tuple[List[models.InvoiceHeader], int]: """Get all invoices for a tenant/company with pagination and optional filters""" query = db.query(models.InvoiceHeader).filter( models.InvoiceHeader.tenant_id == tenant_id, models.InvoiceHeader.company_id == company_id, ) # Apply filters if provided print(f"DEBUG: Invoice Query - Company: {company_id}, Filters: {filters}, Skip: {skip}, Limit: {limit}") if filters: # Join compliance_mx if needed for filters needs_compliance_join = any(k in filters for k in ["pedimento", "manifest_number"]) if needs_compliance_join: query = query.join(models.InvoiceComplianceMx) if filters.get("status") is not None: status_val = filters["status"] if status_val in [True, "processed", models.InvoiceStatus.PROCESSED]: target_status = models.InvoiceStatus.PROCESSED elif status_val in [False, "pending", models.InvoiceStatus.PENDING]: target_status = models.InvoiceStatus.PENDING else: target_status = status_val query = query.filter(models.InvoiceHeader.status == target_status) if filters.get("operation_type"): ot = filters["operation_type"] ot_val = ot.value if hasattr(ot, "value") else ot query = query.filter(models.InvoiceHeader.operation_type == ot_val) if filters.get("invoice_type"): query = query.filter( models.InvoiceHeader.invoice_type == filters["invoice_type"] ) if filters.get("invoice_number"): query = query.filter( models.InvoiceHeader.invoice_number.ilike( f"%{filters['invoice_number']}%" ) ) if filters.get("project_number"): query = query.filter( models.InvoiceHeader.project_number.ilike( f"%{filters['project_number']}%" ) ) if filters.get("year"): try: year_val = int(filters["year"]) query = query.filter( func.extract("year", models.InvoiceHeader.invoice_date) == year_val ) except (ValueError, TypeError): pass if filters.get("pedimento"): from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos query = query.join(models.InvoiceComplianceMx.pedimento).filter( Pedimentos.pedimento_number.ilike(f"%{filters['pedimento']}%") ) if filters.get("manifest_number"): query = query.filter( models.InvoiceComplianceMx.manifest_number.ilike( f"%{filters['manifest_number']}%" ) ) # Special case for exports: exclude REPAR if no invoice_type specified ot_exp = filters.get("operation_type") ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp if not filters.get("invoice_type") and ot_exp_val == "exp": query = query.filter(models.InvoiceHeader.invoice_type != "REPAR") # Filtro por permisos granulares (allowed_types) if "allowed_types" in filters: allowed = filters["allowed_types"] if allowed is None: # Acceso global (admin o view_all) - no filtramos por tipos pass elif not allowed: # Seguridad: Si el usuario NO tiene permisos para ningún tipo específico query = query.filter(models.InvoiceHeader.id == -1) else: conditions = [] for op, inv in allowed: # Aseguramos comparación insensible a mayúsculas para mayor robustez con la DB op_str = str(op).lower() inv_str = str(inv).lower() conditions.append( and_( func.lower(models.InvoiceHeader.operation_type) == op_str, func.lower(models.InvoiceHeader.invoice_type) == inv_str ) ) if conditions: query = query.filter(or_(*conditions)) else: # Seguridad: Si tiene allowed_types pero no generamos condiciones, no debe ver nada query = query.filter(models.InvoiceHeader.id == -1) # Apply sorting if sort_by: # Simple column mapping # This can be improved to handle joins if needed column = getattr(models.InvoiceHeader, 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 if none provided query = query.order_by(models.InvoiceHeader.id.desc()) total = query.count() items = query.offset(skip).limit(limit).all() # Keep party_count aligned with the real number of line items. # This avoids stale values stored in invoice_header.party_count. if items: invoice_ids = [inv.id for inv in items] counts = ( db.query(LineItem.invoice_id, func.count(LineItem.id)) .filter( LineItem.invoice_id.in_(invoice_ids), LineItem.tenant_id == tenant_id, LineItem.company_id == company_id, ) .group_by(LineItem.invoice_id) .all() ) count_map = {invoice_id: int(count) for invoice_id, count in counts} for inv in items: inv.party_count = count_map.get(inv.id, 0) return items, total @staticmethod def create( db: Session, invoice_data: schemas.InvoiceHeaderCreate, tenant_id: int, company_id: int, ) -> models.InvoiceHeader: """Create a new invoice with all related data""" # Validaciones con ErrorCollector errors = ErrorCollector() # Autocalculo remesa (si aplica) ANTES de validar _autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id) # DEBUG: Log payload for analysis print(f"DEBUG: Creating invoice {invoice_data.invoice_number} of type {invoice_data.invoice_type}") print(f"DEBUG: Payload: {invoice_data.model_dump()}") # Validar si la factura ya existe invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors) if invoice_data.operation_type == "exp": validate_create_export(db, invoice_data, tenant_id, company_id, errors) else: validate_create_import(db, invoice_data, tenant_id, company_id, errors) # Si hay errores, lanzar excepción ANTES de intentar crear errors.raise_if_errors("Error al crear la factura") # Extract nested data compliance_data = invoice_data.compliance_mx financials_data = invoice_data.financials logistics_data = invoice_data.logistics details_data = invoice_data.details or [] collections_data = invoice_data.collections or [] try: # Create main invoice header raw_invoice_dict = invoice_data.model_dump( exclude={ "compliance_mx", "financials", "logistics", "details", "collections", } ) invoice_dict = clean_dict(raw_invoice_dict) invoice_dict["tenant_id"] = tenant_id invoice_dict["company_id"] = company_id # Automatic status and audit fields username = _get_current_username() invoice_dict["capture_user"] = username invoice_dict["who_processed"] = username # Ensure document_type respects DB constraints for MEX/AME invoices (bypass clean_dict) if invoice_dict.get("invoice_type") in ["MEX", "AME"] and not invoice_dict.get("document_type"): invoice_dict["document_type"] = None new_invoice = models.InvoiceHeader(**invoice_dict) db.add(new_invoice) db.flush() # Flush to get the invoice ID # Create compliance_mx if provided if compliance_data: raw_comp_dict = compliance_data.model_dump() compliance_dict = clean_dict(raw_comp_dict) compliance_dict["invoice_id"] = new_invoice.id compliance_dict["tenant_id"] = tenant_id compliance_dict["company_id"] = company_id new_compliance = models.InvoiceComplianceMx(**compliance_dict) db.add(new_compliance) # Create financials if provided if financials_data: raw_fin_dict = financials_data.model_dump() financials_dict = clean_dict(raw_fin_dict) financials_dict["invoice_id"] = new_invoice.id financials_dict["tenant_id"] = tenant_id financials_dict["company_id"] = company_id new_financials = models.InvoiceFinancials(**financials_dict) db.add(new_financials) # Create logistics entries if logistics_data: raw_log_dict = logistics_data.model_dump() logistics_dict = clean_dict(raw_log_dict) logistics_dict["invoice_id"] = new_invoice.id logistics_dict["tenant_id"] = tenant_id logistics_dict["company_id"] = company_id _autofill_transport_int_ids( db, logistics_dict, tenant_id=tenant_id, company_id=company_id, ) new_logistics = models.InvoiceLogistics(**logistics_dict) db.add(new_logistics) # Create sales details for detail_item in details_data: raw_det_dict = detail_item.model_dump() detail_dict = clean_dict(raw_det_dict) detail_dict["invoice_id"] = new_invoice.id detail_dict["tenant_id"] = tenant_id detail_dict["company_id"] = company_id new_detail = models.InvoiceSalesDetails(**detail_dict) db.add(new_detail) # Create collections for collection_item in collections_data: raw_col_dict = collection_item.model_dump() collection_dict = clean_dict(raw_col_dict) collection_dict["invoice_id"] = new_invoice.id collection_dict["tenant_id"] = tenant_id collection_dict["company_id"] = company_id new_collection = models.InvoiceCollections(**collection_dict) db.add(new_collection) db.commit() db.refresh(new_invoice) backup_written = _ensure_create_audit_log( db, table_name=new_invoice.__tablename__, record_id=str(new_invoice.id), record_data={ c.name: getattr(new_invoice, c.name) for c in models.InvoiceHeader.__table__.columns }, username=username, tenant_id=tenant_id, company_id=company_id, ) for detail in new_invoice.details: backup_written = ( _ensure_create_audit_log( db, table_name=detail.__tablename__, record_id=str(detail.id), record_data={ c.name: getattr(detail, c.name) for c in models.InvoiceSalesDetails.__table__.columns }, username=username, tenant_id=tenant_id, company_id=company_id, ) or backup_written ) if backup_written: db.commit() return new_invoice except Exception as e: db.rollback() print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥") print(f"Error: {str(e)}") traceback.print_exc() # Esto imprime el error real en la consola print("--------------------------------\n") raise e @staticmethod def update( db: Session, invoice_id: int, tenant_id: int, invoice_data: schemas.InvoiceHeaderUpdate, company_id: int, ) -> Optional[models.InvoiceHeader]: """Update an existing invoice with validation""" # DEBUG: Log payload for analysis print(f"DEBUG: Updating invoice ID {invoice_id} of type {invoice_data.invoice_type}") print(f"DEBUG: Payload: {invoice_data.model_dump(exclude_unset=True)}") # Validaciones con ErrorCollector errors = ErrorCollector() # Obtener la factura existente invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) if not invoice: return None # Si se cambió el número de factura, validar que no exista otra con ese número if invoice_data.invoice_number and invoice_data.invoice_number != invoice.invoice_number: # Verificar que no exista otra factura con el nuevo número existing_invoice = ( db.query(models.InvoiceHeader.id) .filter( models.InvoiceHeader.invoice_number == invoice_data.invoice_number, models.InvoiceHeader.tenant_id == tenant_id, models.InvoiceHeader.company_id == company_id, models.InvoiceHeader.id != invoice_id, # Excluir la factura actual ) .first() ) if existing_invoice: errors.add_duplicate_error( "invoice_number", invoice_data.invoice_number, f"Ya existe otra factura con el número '{invoice_data.invoice_number}'", ) if invoice_data.operation_type == "exp": validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors) else: # Autocalculo remesa (si aplica) ANTES de validar _autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id) validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors) # Si hay errores, lanzar excepción ANTES de actualizar errors.raise_if_errors("Error al actualizar la factura") # Update main invoice header fields update_dict = invoice_data.model_dump( exclude={ "id", "compliance_mx", "financials", "logistics", "details", "collections", }, exclude_unset=True, ) for key, value in update_dict.items(): setattr(invoice, key, value) # Audit update fields username = _get_current_username() invoice.who_processed = username invoice.updated_date = func.now() # Backfill capture_user if missing or previous generic 'System' if not invoice.capture_user or invoice.capture_user == "System": if username != "System": invoice.capture_user = username # Update compliance_mx if provided if invoice_data.compliance_mx is not None: print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}") if invoice.compliance_mx: for key, value in invoice_data.compliance_mx.model_dump( exclude_unset=True ).items(): # Parche rápido para update if value == "": value = None setattr(invoice.compliance_mx, key, value) else: compliance_dict = invoice_data.compliance_mx.model_dump() # Aplicar limpieza manual si es necesario if "customs_agent" in compliance_dict: compliance_dict["customs_broker_id"] = compliance_dict.pop( "customs_agent" ) compliance_dict["invoice_id"] = invoice.id compliance_dict["tenant_id"] = tenant_id compliance_dict["company_id"] = company_id new_compliance = models.InvoiceComplianceMx(**compliance_dict) db.add(new_compliance) # Update financials if provided if invoice_data.financials is not None: if invoice.financials: for key, value in invoice_data.financials.model_dump( exclude_unset=True ).items(): if value == "": value = None setattr(invoice.financials, key, value) else: financials_dict = invoice_data.financials.model_dump() financials_dict["invoice_id"] = invoice.id financials_dict["tenant_id"] = tenant_id financials_dict["company_id"] = company_id new_financials = models.InvoiceFinancials(**financials_dict) db.add(new_financials) # Update logistics if provided if invoice_data.logistics is not None: if invoice.logistics: for key, value in invoice_data.logistics.model_dump( exclude_unset=True ).items(): if value == "": value = None setattr(invoice.logistics, key, value) _autofill_transport_int_ids( db, invoice.logistics, tenant_id=tenant_id, company_id=company_id, ) else: logistics_dict = invoice_data.logistics.model_dump() logistics_dict["invoice_id"] = invoice.id logistics_dict["tenant_id"] = tenant_id logistics_dict["company_id"] = company_id _autofill_transport_int_ids( db, logistics_dict, tenant_id=tenant_id, company_id=company_id, ) new_logistics = models.InvoiceLogistics(**logistics_dict) db.add(new_logistics) db.commit() db.refresh(invoice) return invoice @staticmethod def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool: """Delete an invoice and all related data (cascade delete)""" invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) if invoice: db.delete(invoice) db.commit() return True return False