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.

This commit is contained in:
Galindo97
2026-02-06 17:57:58 -06:00
parent f3f763cd6d
commit 84b507fbba
7 changed files with 137 additions and 35 deletions

View File

@@ -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 ''

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;