From 84b507fbba5a708466839bd865bf93078f810971 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 6 Feb 2026 17:57:58 -0600 Subject: [PATCH] feat: Enhance invoice movement reports by refactoring CSV generation with a filter object, expanding exported fields, improving formatting, and enabling direct download in the frontend. --- .../modules/a76/reports/movements/__init__.py | 0 .../reports/movements/invoices/csv_utils.py | 105 +++++++++++++----- .../a76/reports/movements/invoices/routes.py | 2 +- .../a76/reports/movements/invoices/tasks.py | 2 +- backend/requirements.txt | 4 +- docker-compose.yml | 16 +++ .../dashboard/reports/invoices/+page.svelte | 43 ++++++- 7 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/__init__.py diff --git a/backend/api/v1/modules/a76/reports/movements/__init__.py b/backend/api/v1/modules/a76/reports/movements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py index 004b7ae4..2c91af6d 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -6,32 +6,42 @@ import io from typing import List, Union from datetime import datetime -from .schemas import MovementItem, MovementItemDetailed +from .schemas import MovementItem, MovementItemDetailed, AllMovementsFilter def generate_csv_from_movements( movements: List[Union[MovementItem, MovementItemDetailed]], - report_type: str = "normal" + filters: AllMovementsFilter ) -> str: """ Generate CSV content from movement items. Args: movements: List of movement items (normal or detailed) - report_type: "normal" or "detailed" + filters: Filter object containing report parameters Returns: CSV content as string """ output = io.StringIO() - if report_type.lower() == "normal": - # Normal report columns + if filters.report_type.value.lower() == "normal": + # Normal report - only fields that are actually populated fieldnames = [ - 'Pedimento', 'ClavePed', 'Factura', 'FechaFactura', - 'ValorComercialMN', 'TipoMovTemDef', 'Estatus', 'BaseDeDatos', - 'TipoCambio', 'Fecha_Pago', 'ValorMPTemp', 'ValorAgre', - 'TipoExpo', 'EsCambioRegimen', 'Regimen' + # Identification + 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Values + 'ValorComercialMN', 'ValorMPTemp', 'TipoCambio', 'ValorAgre', + # Classification + 'TipoMovTemDef', 'Estatus', 'TipoExpo', 'EsCambioRegimen', + # Dates + 'Fecha_Pago', + # References + 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Logistics + 'NumCaja', 'NumGafUni', 'AduanaCru', + # Metadata + 'BaseDeDatos', 'UsuarioCap', 'UsuarioAcr' ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') @@ -40,21 +50,45 @@ def generate_csv_from_movements( for movement in movements: row = movement.model_dump() # Format datetime fields - if row.get('FechaFactura'): - row['FechaFactura'] = _format_datetime(row['FechaFactura']) - if row.get('Fecha_Pago'): - row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['ValorMPTemp'] = _format_decimal(row.get('ValorMPTemp')) + row['ValorAgre'] = _format_decimal(row.get('ValorAgre')) + writer.writerow(row) else: - # Detailed report columns + # Detailed report - only fields that are actually populated fieldnames = [ - 'Linea', 'Pedimento', 'Factura', 'FechaFactura', - 'Proveedor', 'VendidoA', 'CantidadIE', 'DescripcionE', - 'DescripcionI', 'NumParte', 'UniMed', 'ValorComercialMN', - 'TipoMovTemDef', 'ClavePed', 'Estatus', 'TipoCambio', - 'PesoNeto', 'PesoBruto', 'OrdenCompraVenta', 'Regimen', - 'AgenteAduanal', 'Patente', 'BaseDeDatos' + # Identification + 'Linea', 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Parties (names only, no RFC/TaxID as they're not in queries) + 'Proveedor', 'VendidoA', + # Product + 'NumParte', 'DescripcionE', 'DescripcionI', 'CantidadIE', 'UniMed', + # Classification + 'FraccionArancelaria', 'FraccionAmericana', 'ECCN', 'Sector', 'PaisOrigen', + # Values + 'ValorComercialMN', 'TipoCambio', 'PesoNeto', 'PesoBruto', + # Customs + 'TipoMovTemDef', 'Regimen', 'Aduana', 'Advalorem', 'Preferencia', + # Customs Broker + 'AgenteAduanal', 'Patente', + # References + 'OrdenCompraVenta', 'Remesa', 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Identifiers + 'Series', 'Marca', 'Modelo', 'SimboloEx', + # Dates + 'Fecha_Pago', 'Fecha_Inicio', 'Fecha_Fin', 'FechaEmision', + # Logistics + 'Transportista', 'NumCaja', 'NumGafUni', 'AduanaCru', 'Lote', + # Metadata + 'Estatus', 'BaseDeDatos', 'TipoExpo', 'EsCambioRegimen', 'Pedimento18', + 'UsuarioCap', 'UsuarioAcr' ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') @@ -63,14 +97,19 @@ def generate_csv_from_movements( for movement in movements: row = movement.model_dump() # Format datetime fields - if row.get('FechaFactura'): - row['FechaFactura'] = _format_datetime(row['FechaFactura']) - if row.get('Fecha_Pago'): - row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) - if row.get('Fecha_Inicio'): - row['Fecha_Inicio'] = _format_datetime(row['Fecha_Inicio']) - if row.get('Fecha_Fin'): - row['Fecha_Fin'] = _format_datetime(row['Fecha_Fin']) + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + row['Fecha_Inicio'] = _format_datetime(row.get('Fecha_Inicio')) + row['Fecha_Fin'] = _format_datetime(row.get('Fecha_Fin')) + row['FechaEmision'] = _format_datetime(row.get('FechaEmision')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['CantidadIE'] = _format_decimal(row.get('CantidadIE')) + row['PesoNeto'] = _format_decimal(row.get('PesoNeto')) + row['PesoBruto'] = _format_decimal(row.get('PesoBruto')) + writer.writerow(row) csv_content = output.getvalue() @@ -85,3 +124,13 @@ def _format_datetime(dt) -> str: elif isinstance(dt, str): return dt return '' + + +def _format_decimal(value, decimals: int = 2) -> str: + """Format decimal values for CSV export.""" + if value is None: + return '' + try: + return f"{float(value):.{decimals}f}" + except (ValueError, TypeError): + return str(value) if value else '' diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py index 0d406f07..351e4d9e 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -652,7 +652,7 @@ async def get_all_movements( # Generate CSV csv_content = generate_csv_from_movements( movements=movements, - report_type=filters.report_type.value.lower() + filters=filters ) # Generate filename diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py index a2de6aef..47114e98 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -40,7 +40,7 @@ def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_ema # 4. Generate CSV csv_content = generate_csv_from_movements( movements=movements, - report_type=filters.report_type.value.lower() + filters=filters ) # 5. Send Email if requested diff --git a/backend/requirements.txt b/backend/requirements.txt index d21c8780..7480be9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,6 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1asgiref==3.8.1 +pdf417gen==0.8.1 +asgiref==3.8.1 +aiosmtplib==3.0.1 diff --git a/docker-compose.yml b/docker-compose.yml index 041c1bdf..a6dc68de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -272,12 +272,28 @@ services: container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-development} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - VALKEY_URL=redis://valkey:6379/0 depends_on: - backend - valkey networks: - backend-net + volumes: + - ./backend:/app + valkey: image: valkey/valkey:7.2 diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 33bc86c2..97dff1c6 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -402,10 +402,45 @@ if (statusData.status === 'SUCCESS') { clearInterval(pollInterval); loading = false; - toast.success( - 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', - { id: toastId } - ); + + // Download the file automatically + if (statusData.result?.content) { + try { + // Decode base64 content + const base64Content = statusData.result.content; + const binaryString = atob(base64Content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create blob and download + const blob = new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = statusData.result.file_name || 'reporte_facturas.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success('Reporte generado y descargado. TambiƩn se ha enviado por correo.', { + id: toastId + }); + } catch (downloadErr) { + console.error('Error downloading file:', downloadErr); + toast.success( + 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + { id: toastId } + ); + } + } else { + toast.success( + 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + { id: toastId } + ); + } } else if (statusData.status === 'FAILURE') { clearInterval(pollInterval); loading = false;