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:
@@ -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 ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user