feat: Implement asynchronous invoice report generation with email delivery and frontend status polling.

This commit is contained in:
Galindo97
2026-02-06 15:31:30 -06:00
parent 264100e2ea
commit f3f763cd6d
14 changed files with 797 additions and 400 deletions

View File

@@ -0,0 +1,87 @@
"""
CSV generation utilities for invoice movement reports.
"""
import csv
import io
from typing import List, Union
from datetime import datetime
from .schemas import MovementItem, MovementItemDetailed
def generate_csv_from_movements(
movements: List[Union[MovementItem, MovementItemDetailed]],
report_type: str = "normal"
) -> str:
"""
Generate CSV content from movement items.
Args:
movements: List of movement items (normal or detailed)
report_type: "normal" or "detailed"
Returns:
CSV content as string
"""
output = io.StringIO()
if report_type.lower() == "normal":
# Normal report columns
fieldnames = [
'Pedimento', 'ClavePed', 'Factura', 'FechaFactura',
'ValorComercialMN', 'TipoMovTemDef', 'Estatus', 'BaseDeDatos',
'TipoCambio', 'Fecha_Pago', 'ValorMPTemp', 'ValorAgre',
'TipoExpo', 'EsCambioRegimen', 'Regimen'
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
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'])
writer.writerow(row)
else:
# Detailed report columns
fieldnames = [
'Linea', 'Pedimento', 'Factura', 'FechaFactura',
'Proveedor', 'VendidoA', 'CantidadIE', 'DescripcionE',
'DescripcionI', 'NumParte', 'UniMed', 'ValorComercialMN',
'TipoMovTemDef', 'ClavePed', 'Estatus', 'TipoCambio',
'PesoNeto', 'PesoBruto', 'OrdenCompraVenta', 'Regimen',
'AgenteAduanal', 'Patente', 'BaseDeDatos'
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
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'])
writer.writerow(row)
csv_content = output.getvalue()
output.close()
return csv_content
def _format_datetime(dt) -> str:
"""Format datetime for CSV export."""
if isinstance(dt, datetime):
return dt.strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(dt, str):
return dt
return ''

View File

@@ -154,16 +154,25 @@ class MovementService:
# Convert AllMovementsFilter to individual filter types
# We'll use the same filter parameters for all queries
# Determine which services to call based on operation_type filter
should_fetch_imports = filters.operation_type in [None, 'imp']
should_fetch_exports = filters.operation_type in [None, 'exp']
# Determine which services to call based on granular flags
# Default behavior: If granular flags are all defaults (True) but operation_type is set,
# we might need to respect operation_type.
# But for simplicity, we assume granular flags from frontend are the source of truth.
# If frontend didn't set them (legacy call?), they default to True.
logger.info(f"Operation type filter: {filters.operation_type}")
logger.info(f"Should fetch imports: {should_fetch_imports}")
logger.info(f"Should fetch exports: {should_fetch_exports}")
# Override based on operation_type if provided (legacy compatibility or coarse filter)
if filters.operation_type == 'imp':
filters.export_def = False
filters.export_rep = False
elif filters.operation_type == 'exp':
filters.import_temp = False
filters.import_def = False
filters.import_rep = False
logger.info(f"Fetching movements with flags: Temp={filters.import_temp}, Def={filters.import_def}, Rep={filters.import_rep}, ExpDef={filters.export_def}, ExpRep={filters.export_rep}")
# 1. Temporary Imports
if should_fetch_imports:
if filters.import_temp:
temp_filter = ImportTemporaryFilter(
range_type=filters.range_type,
start_date=filters.start_date,
@@ -172,11 +181,11 @@ class MovementService:
provider=filters.provider,
buyer=filters.buyer,
pedimento_code=filters.pedimento_code,
report_type=filters.report_type, # Use filter's report_type
report_type=filters.report_type,
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default' # Required field
database_name='default'
)
if filters.report_type == ReportType.DETAILED:
temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter)
@@ -186,7 +195,7 @@ class MovementService:
logger.info(f"Added {len(temp_movements)} temporary import movements")
# 2. Definitive Imports
if should_fetch_imports:
if filters.import_def:
def_filter = ImportDefinitiveFilter(
range_type=filters.range_type,
start_date=filters.start_date,
@@ -211,7 +220,7 @@ class MovementService:
logger.info(f"Added {len(def_movements)} definitive import movements")
# 3. Repair Imports
if should_fetch_imports:
if filters.import_rep:
repair_filter = ImportRepairFilter(
range_type=filters.range_type,
start_date=filters.start_date,
@@ -236,7 +245,7 @@ class MovementService:
logger.info(f"Added {len(repair_movements)} repair import movements")
# 4. Exports (Definitive)
if should_fetch_exports:
if filters.export_def:
export_filter = ExportFilter(
range_type=filters.range_type,
start_date=filters.start_date,
@@ -262,7 +271,7 @@ class MovementService:
logger.info(f"Added {len(export_movements)} export movements")
# 5. Export Repairs
if should_fetch_exports:
if filters.export_rep:
export_repair_filter = ExportRepairFilter(
range_type=filters.range_type,
start_date=filters.start_date,

View File

@@ -604,9 +604,11 @@ def get_export_repair_movements_detailed(
Use this when "TODAS" checkbox is selected to get a comprehensive view of all movements
regardless of their specific type.
If send_email is True, the report will be sent to the authenticated user's email address.
"""
)
def get_all_movements(
async def get_all_movements(
filters: AllMovementsFilter,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
@@ -628,13 +630,52 @@ def get_all_movements(
try:
logger.info(
f"User {current_user.get('preferred_username', 'unknown')} "
f"requesting all invoice movements"
f"requesting all invoice movements (send_email={filters.send_email})"
)
movements = movement_service.get_all_movements(
db=db,
filters=filters
)
logger.info(f"Successfully retrieved {len(movements)} total movements")
# Send email if requested
if filters.send_email:
user_email = current_user.get('email')
if not user_email:
logger.warning(f"User {current_user.get('sub')} has no email address - skipping email")
else:
try:
from core.email import EmailService
from .csv_utils import generate_csv_from_movements
from datetime import datetime
# Generate CSV
csv_content = generate_csv_from_movements(
movements=movements,
report_type=filters.report_type.value.lower()
)
# Generate filename
filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
# Send email
email_sent = await EmailService.send_report_email(
recipient_email=user_email,
subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}",
body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.",
csv_content=csv_content,
filename=filename
)
if email_sent:
logger.info(f"Report emailed successfully to {user_email}")
else:
logger.warning(f"Failed to send email to {user_email} - SMTP may not be configured correctly")
except Exception as email_error:
logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation")
return movements
return movements
except ValueError as e:
logger.warning(f"Validation error fetching all movements: {str(e)}")
@@ -642,9 +683,66 @@ def get_all_movements(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching all movements: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error processing all movements: {str(e)}"
)
@router.post(
"/generate",
summary="Generate Invoice Report (Async)",
description="Trigger background generation of invoice report."
)
def generate_invoice_report_async(
filters: AllMovementsFilter,
current_user: dict = Depends(get_current_user)
):
"""
Trigger background generation of invoice report.
Returns task_id to poll status.
"""
from .tasks import generate_invoice_movements_async
logger.info(f"User {current_user.get('preferred_username', 'unknown')} triggering async report generation")
# Serialize filters to dict for Celery
filter_data = filters.model_dump()
user_email = current_user.get('email')
# Trigger task
task = generate_invoice_movements_async.delay(filter_data, user_email)
return {"task_id": task.id}
@router.get(
"/task/{task_id}",
summary="Get Async Task Status",
description="Check status of background report generation task."
)
def get_task_status(task_id: str):
"""
Get status of background task.
"""
from celery.result import AsyncResult
from core.celery_app import celery_app
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"status": task_result.status,
}
if task_result.state == 'PROCESSING':
response["meta"] = task_result.info
if task_result.ready():
response["result"] = task_result.result
return response

View File

@@ -103,6 +103,27 @@ class AllMovementsFilter(BaseModel):
default=None,
description="Filter by operation type: 'imp' for imports only, 'exp' for exports only, None for all"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
# Granular movement selection
import_temp: bool = Field(default=True, description="Include temporary imports (IMTEM)")
import_def: bool = Field(default=True, description="Include definitive imports (IMPDF/COMEX)")
import_rep: bool = Field(default=True, description="Include repair imports (IMPRE)")
export_def: bool = Field(default=True, description="Include definitive exports")
export_rep: bool = Field(default=True, description="Include repair exports")
# Specific filters
export_types: Optional[list[str]] = Field(
default=None,
description="Specific export legacy codes to include (AFIJO, NODES, etc)"
)
discharge_filter: DischargeFilter = Field(
default=DischargeFilter.ALL,
description="Global discharge filter for repair movements"
)
class ImportTemporaryFilter(BaseModel):
@@ -155,6 +176,10 @@ class ImportTemporaryFilter(BaseModel):
...,
description="Legacy database name to query from"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
class ImportDefinitiveFilter(BaseModel):
@@ -215,6 +240,10 @@ class ImportDefinitiveFilter(BaseModel):
...,
description="Legacy database name to query from"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
class MovementItem(BaseModel):
"""Movement item representing a temporary import invoice"""
@@ -317,6 +346,10 @@ class ImportRepairFilter(BaseModel):
...,
description="Legacy database name to query from"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
class MovementItemDetailed(BaseModel):
@@ -464,6 +497,10 @@ class ExportFilter(BaseModel):
...,
description="Legacy database name"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
model_config = {
"json_schema_extra": {
@@ -545,6 +582,10 @@ class ExportRepairFilter(BaseModel):
...,
description="Legacy database name"
)
send_email: bool = Field(
default=False,
description="Send report via email to current user"
)
model_config = {
"json_schema_extra": {

View File

@@ -353,7 +353,14 @@ class RepairImportQueries:
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
discharge_filter = "" # Temporarily disabled until schema migration
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
ih.invoice_number AS C2,
@@ -403,9 +410,13 @@ class RepairImportQueries:
@staticmethod
def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build main SQL query for repair import data."""
# Note: is_discharged field not yet migrated to PostgreSQL schema
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
discharge_filter = "" # Temporarily disabled until schema migration
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
il.line_number,
@@ -479,9 +490,13 @@ class RepairImportQueries:
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for a repair import invoice."""
# Note: is_discharged field not yet migrated to PostgreSQL schema
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
discharge_filter = "" # Temporarily disabled until schema migration
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
@@ -651,7 +666,13 @@ class ExportQueries:
Only sums partidas where is_subitem is false (main partidas, not sub-items).
"""
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
@@ -689,8 +710,14 @@ class ExportRepairQueries:
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
# Note: discharge_clause temporarily disabled until is_discharged field migrated
discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
ih.invoice_number AS C1,
@@ -817,7 +844,13 @@ class ExportRepairQueries:
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for an export repair invoice."""
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
# Use fa_item_lines.download field (true = discharged, false = not discharged)
if "SiDes" in discharge_clause:
discharge_filter = "AND fil.download = true"
elif "NoDes" in discharge_clause:
discharge_filter = "AND fil.download = false"
else:
discharge_filter = ""
return f"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),

View File

@@ -233,10 +233,10 @@ class TemporaryImportService:
# ValorComercialMN should always be in MXN
# If currency_type is ME, valor_comercial is in USD, so multiply by tipo_cambio
if filters.currency_type.value == "ME" and tipo_cambio:
valor_comercial_mn = valor_comercial * tipo_cambio
valor_comercial_mn = float(valor_comercial) * float(tipo_cambio)
else:
# If currency_type is MN, valor_comercial is already in MXN
valor_comercial_mn = valor_comercial
valor_comercial_mn = float(valor_comercial)
# Set peso values based on subpartida flag
if row[39] == 'P': # C40 - EsSubPartida

View File

@@ -0,0 +1,108 @@
import base64
import logging
import traceback
from typing import Dict, Any
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.email import EmailService
from datetime import datetime
from .movement_service import movement_service
from .schemas import AllMovementsFilter
from .csv_utils import generate_csv_from_movements
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="generate_invoice_movements_async")
def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_email: str = None):
"""
Async task to generate invoice movements report.
FETCHES data -> GENERATES CSV -> SENDS EMAIL (optional) -> RETURNS CSV (base64)
"""
db = CoreSessionLocal()
try:
# 1. Update Progress
self.update_state(state='PROCESSING', meta={'current': 10, 'total': 100, 'status': 'Inicializando reporte...'})
# 2. Reconstruct Filter
filters = AllMovementsFilter(**filter_data)
# 3. Fetch Data
self.update_state(state='PROCESSING', meta={'current': 30, 'total': 100, 'status': 'Obteniendo movimientos de base de datos...'})
logger.info(f"Async Task: Fetching movements for {filters}")
movements = movement_service.get_all_movements(db=db, filters=filters)
self.update_state(state='PROCESSING', meta={'current': 70, 'total': 100, 'status': f'Procesando {len(movements)} registros...'})
# 4. Generate CSV
csv_content = generate_csv_from_movements(
movements=movements,
report_type=filters.report_type.value.lower()
)
# 5. Send Email if requested
email_sent = False
if filters.send_email and user_email:
self.update_state(state='PROCESSING', meta={'current': 90, 'total': 100, 'status': 'Enviando correo electrónico...'})
try:
# Generate filename
filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
# Send email (using the new async wrapper or run_until_complete if needed,
# but since we are in a sync celery task we might need to be careful with async/await.
# Actually EmailService.send_report_email is async.
# We need to run it synchronously here or make the task async.
# Celery tasks are sync by default. We can use asgiref.sync.async_to_sync
import asyncio
from asgiref.sync import async_to_sync
# Helper to run async method
result = async_to_sync(EmailService.send_report_email)(
recipient_email=user_email,
subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}",
body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.",
csv_content=csv_content,
filename=filename
)
if result:
email_sent = True
logger.info(f"Async Task: Email sent to {user_email}")
else:
logger.warning(f"Async Task: Failed to send email to {user_email}")
except Exception as e:
logger.error(f"Async Task: Email error: {str(e)}")
# 6. Encode and Return
self.update_state(state='PROCESSING', meta={'current': 95, 'total': 100, 'status': 'Finalizando...'})
# Convert string csv to bytes then base64
pdf_b64 = base64.b64encode(csv_content.encode('utf-8')).decode('utf-8')
return {
'status': 'success',
'file_name': f"reporte_facturas_{datetime.now().strftime('%Y%m%d')}.csv",
'content': pdf_b64,
'media_type': 'text/csv',
'email_sent': email_sent,
'total_records': len(movements)
}
except Exception as e:
logger.error(f"Error in generate_invoice_movements_async: {str(e)}", exc_info=True)
self.update_state(
state='FAILURE',
meta={
'exc_type': type(e).__name__,
'exc_message': str(e),
'custom': 'Error generating report'
}
)
raise e
finally:
db.close()

View File

@@ -56,7 +56,6 @@ from .reports.exportacion.aviso_consolidado.routes import router as aviso_consol
from .reports.movements.invoices.routes import router as movement_invoices_router
# Router principal
router = APIRouter()

View File

@@ -12,7 +12,8 @@ celery_app = Celery(
"api.v1.modules.a76.reports.importacion.facturas.task",
"api.v1.modules.a76.reports.importacion.consolidados.task",
"api.v1.modules.a76.reports.importacion.packing_list.task",
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task"
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
"api.v1.modules.a76.reports.movements.invoices.tasks"
] # Ruta al módulo donde están las tareas
)

View File

@@ -47,6 +47,14 @@ class Settings(BaseSettings):
SITAR_API_USER: str = ""
SITAR_API_PASSWORD: str = ""
# SMTP Email Configuration
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM_NAME: str = "Sistema Anexo76"
SMTP_USE_TLS: bool = True
model_config = SettingsConfigDict(
env_file=[".env", "../.env"],
case_sensitive=True,

117
backend/core/email.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Email service for sending reports via SMTP.
"""
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from typing import List
import logging
from datetime import datetime
from core.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails with attachments."""
@staticmethod
async def send_report_email(
recipient_email: str,
subject: str,
body_text: str,
csv_content: str,
filename: str
) -> bool:
"""
Send a report email with CSV attachment.
Args:
recipient_email: Email address of recipient
subject: Email subject line
body_text: Plain text email body
csv_content: CSV file content as string
filename: Name for the CSV attachment
Returns:
bool: True if email sent successfully, False otherwise
"""
try:
# Create message
msg = MIMEMultipart()
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
msg['To'] = recipient_email
msg['Subject'] = subject
# Email body
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">
Reporte de Facturas - Sistema Anexo76
</h2>
<p>{body_text}</p>
<p style="margin-top: 20px;">
El reporte se encuentra adjunto en formato CSV.
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 12px; color: #6b7280;">
Este es un correo generado automáticamente. Por favor no responder.
</p>
<p style="font-size: 12px; color: #6b7280;">
Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}
</p>
</div>
</body>
</html>
"""
msg.attach(MIMEText(html_body, 'html'))
# CSV attachment
attachment = MIMEBase('text', 'csv')
attachment.set_payload(csv_content.encode('utf-8'))
encoders.encode_base64(attachment)
attachment.add_header(
'Content-Disposition',
f'attachment; filename="{filename}"'
)
msg.attach(attachment)
# Create SSL context that ignores certificate errors
import ssl
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# Send email
if settings.SMTP_PORT == 465:
# Port 465 uses implicit SSL
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=True, # Implicit SSL
tls_context=context
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)
else:
# Port 587 uses STARTTLS
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
tls_context=context
) as smtp:
await smtp.starttls(tls_context=context)
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)
logger.info(f"Email sent successfully to {recipient_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {recipient_email}: {str(e)}")
return False

View File

@@ -50,4 +50,4 @@ redis==5.0.1
flower==2.0.1
# Barcode
pdf417gen==0.8.1
pdf417gen==0.8.1asgiref==3.8.1

View File

@@ -62,6 +62,15 @@ export interface AllMovementsFilter {
exchange_rate_type: ExchangeRateType;
is_shelter: boolean;
operation_type?: 'imp' | 'exp' | null;
send_email?: boolean;
// Granular flags
import_temp?: boolean;
import_def?: boolean;
import_rep?: boolean;
export_def?: boolean;
export_rep?: boolean;
export_types?: string[];
discharge_filter?: DischargeFilter;
}
export interface MovementItem {
@@ -181,5 +190,14 @@ export const invoiceMovementsApi = {
// All Movements
getAllMovements: (filters: AllMovementsFilter) =>
api.post<MovementItem[]>('/v1/a76/reports/movements/invoices/all', filters)
api.post<MovementItem[]>('/v1/a76/reports/movements/invoices/all', filters),
// Async Generation
generateReportAsync: (filters: AllMovementsFilter) =>
api.post<{ task_id: string }>('/v1/a76/reports/movements/invoices/generate', filters),
getTaskStatus: (taskId: string) =>
api.get<{ task_id: string; status: string; result?: any; meta?: any }>(
`/v1/a76/reports/movements/invoices/task/${taskId}`
)
};

View File

@@ -28,7 +28,8 @@
ShieldCheck,
Calculator,
Download,
Folder
Folder,
Eye
} from 'lucide-svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Dialog from '$lib/components/ui/dialog';
@@ -346,395 +347,229 @@
// --- LÓGICA ---
async function handleGenerateReport() {
// Validar fechas
const dateValidation = validateDates();
if (!dateValidation.valid) {
toast.error(`Error: ${dateValidation.message}`);
return;
}
// Estado del Context Menu
let contextMenu = $state({
open: false,
x: 0,
y: 0
});
// Validar tipos de movimiento
const movementValidation = validateMovementTypes();
if (!movementValidation.valid) {
toast.error(`Error: ${movementValidation.message}`);
return;
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu = {
open: true,
x: e.clientX,
y: e.clientY
};
}
// Validar reglas de negocio del Clarion
const businessValidation = validateBusinessRules();
if (!businessValidation.valid) {
toast.error(`Error: ${businessValidation.message}`);
return;
function closeContextMenu() {
contextMenu.open = false;
}
// Acción: Generar Reporte (Email / Background) - Click Izquierdo
async function generateReport() {
const filter = buildAllMovementsFilter();
if (!filter) return;
// Forzar envío de correo para esta acción
filter.send_email = true;
loading = true;
// Initial toast
const toastId = toast.loading('Iniciando generación de reporte...');
try {
// 1. Trigger Async Generation
const response = await invoiceMovementsApi.generateReportAsync(filter);
if (!response.data || !response.data.task_id) {
toast.error('Error al iniciar la generación del reporte', { id: toastId });
loading = false;
return;
}
const taskId = response.data.task_id;
// 2. Poll for status
const pollInterval = setInterval(async () => {
try {
const statusResponse = await invoiceMovementsApi.getTaskStatus(taskId);
const statusData = statusResponse.data;
if (!statusData) return;
if (statusData.status === 'SUCCESS') {
clearInterval(pollInterval);
loading = false;
toast.success(
'Reporte generado correctamente. Se ha enviado un correo con los resultados.',
{ id: toastId }
);
} else if (statusData.status === 'FAILURE') {
clearInterval(pollInterval);
loading = false;
// Try to extract specific error from meta or result
const errorMsg =
statusData.meta?.exc_message ||
statusData.result?.exc_message ||
statusData.result?.detail ||
'Error en la generación del reporte';
console.error('Task Failure Details:', statusData);
toast.error(errorMsg, { id: toastId });
} else if (statusData.status === 'PROCESSING') {
// Update progress
if (statusData.meta) {
const { current, total, status } = statusData.meta;
const percentage = Math.round((current / total) * 100);
toast.loading(`${status} (${percentage}%)`, { id: toastId });
}
}
} catch (err) {
console.error('Error polling status:', err);
// Don't stop polling on transient network errors, but maybe log it
}
}, 1000); // Poll every 1 second
} catch (error: any) {
console.error('Error generando reporte:', error);
toast.error(error.message || 'Error al generar el reporte', { id: toastId });
loading = false;
}
}
// Acción: Vista Previa (Tabla) - Click Derecho -> Opción
async function previewReport() {
closeContextMenu();
const filter = buildAllMovementsFilter();
if (!filter) return;
// Para preview, no enviamos correo (o respetamos config, pero generalmente preview es solo ver)
filter.send_email = false;
loading = true;
results = [];
showResults = false;
toast.info('Cargando vista previa...');
try {
// Si "TODAS" en otras opciones o exportaciones está marcado, usar el endpoint especial
if (types.other.TODAS || types.export.additional.TODAS) {
toast.info('Obteniendo todos los movimientos...');
const response = await invoiceMovementsApi.getAllMovements(filter);
// Determinar el tipo de operación basado en cuál "TODAS" está marcado
let operation_type: 'imp' | 'exp' | null = null;
if (types.other.TODAS) {
// "TODAS" de la sección Otras = traer TODO (importaciones + exportaciones)
operation_type = null;
} else if (types.export.additional.TODAS) {
// "TODAS" de exportaciones = solo exportaciones
operation_type = 'exp';
}
const allMovementsFilter: AllMovementsFilter = {
range_type: dates.type === 'invoice' ? 'FF' : 'FP',
start_date: formatDateToYYYYMMDD(dates.from),
end_date: formatDateToYYYYMMDD(dates.to),
include_cancelled: filters.includeNA,
provider: selectors.provider || null,
buyer: selectors.soldTo || null,
pedimento_code: selectors.pedimentoKey || null,
report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado',
currency_type: config.currency === 'foreign' ? 'ME' : 'MN',
exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP',
is_shelter: config.shelter,
operation_type
};
const response = await invoiceMovementsApi.getAllMovements(allMovementsFilter);
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) {
results = response.data;
showResults = true;
}
toast.success(`Se encontraron ${results.length} movimientos`);
return; // Salir temprano, no ejecutar la lógica individual
if (response.error) {
toast.error(response.error);
return;
}
const baseFilter: Omit<BaseFilter, 'database_name'> = {
range_type: dates.type === 'invoice' ? 'FF' : 'FP',
start_date: formatDateToYYYYMMDD(dates.from),
end_date: formatDateToYYYYMMDD(dates.to),
include_cancelled: filters.includeNA,
provider: selectors.provider || null,
buyer: selectors.soldTo || null,
pedimento_code: selectors.pedimentoKey || null,
report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado',
currency_type: config.currency === 'foreign' ? 'ME' : 'MN',
exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP',
is_shelter: config.shelter
};
if (response.data) {
results = response.data;
showResults = true;
const allResults: (MovementItem | MovementItemDetailed)[] = [];
// Importaciones Temporales (IMTEM)
if (types.import.TEM) {
toast.info('Obteniendo importaciones temporales...');
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getTemporaryImports({
...baseFilter,
database_name: 'default'
})
: await invoiceMovementsApi.getTemporaryImportsDetailed({
...baseFilter,
database_name: 'default'
});
if (response.error) {
toast.error(response.error);
return;
if (results.length === 0) {
toast.warning('No se encontraron resultados con los filtros seleccionados');
} else {
toast.success(`${results.length} registros cargados en vista previa`);
}
if (response.data) allResults.push(...response.data);
}
} catch (error: any) {
console.error('Error generando vista previa:', error);
toast.error(error.message || 'Error al generar la vista previa');
} finally {
loading = false;
}
}
// Importaciones Definitivas (IMPDF) o COMEX
if (types.import.DEF) {
toast.info('Obteniendo importaciones definitivas...');
const movementType = types.other.COMEX ? 'COMEX' : 'IMPDF';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getDefinitiveImports({
...baseFilter,
database_name: 'default',
movement_type: movementType
})
: await invoiceMovementsApi.getDefinitiveImportsDetailed({
...baseFilter,
database_name: 'default',
movement_type: movementType
});
// Helper para construir el filtro (extraído de la lógica anterior)
function buildAllMovementsFilter(): AllMovementsFilter | null {
// Validaciones
const dateValidation = validateDates();
if (!dateValidation.valid) {
toast.error(`Error: ${dateValidation.message}`);
return null;
}
const movementValidation = validateMovementTypes();
if (!movementValidation.valid) {
toast.error(`Error: ${movementValidation.message}`);
return null;
}
const businessValidation = validateBusinessRules();
if (!businessValidation.valid) {
toast.error(`Error: ${businessValidation.message}`);
return null;
}
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) allResults.push(...response.data);
}
// Importaciones de Reparación (IMPRE)
if (types.import.REP) {
toast.info('Obteniendo importaciones de reparación...');
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getRepairImports({
...baseFilter,
database_name: 'default',
discharge_filter: dischargeFilter
})
: await invoiceMovementsApi.getRepairImportsDetailed({
...baseFilter,
database_name: 'default',
discharge_filter: dischargeFilter
});
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) allResults.push(...response.data);
}
// Exportaciones Definitivas (incluye VEMEX)
if (
const allMovementsFilter: AllMovementsFilter = {
range_type: dates.type === 'invoice' ? 'FF' : 'FP',
start_date: formatDateToYYYYMMDD(dates.from),
end_date: formatDateToYYYYMMDD(dates.to),
include_cancelled: filters.includeNA,
provider: selectors.provider || null,
buyer: selectors.soldTo || null,
pedimento_code: selectors.pedimentoKey || null,
report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado',
currency_type: config.currency === 'foreign' ? 'ME' : 'MN',
exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP',
is_shelter: config.shelter,
// Granular flags
import_temp: !!types.import.TEM,
import_def: !!types.import.DEF,
import_rep: !!types.import.REP,
export_def: !!(
types.export.main.DEF ||
types.other.VEMEX ||
types.export.additional.TODAS ||
Object.values(types.export.additional).some((v) => v)
) {
toast.info('Obteniendo exportaciones...');
),
export_rep: !!types.export.main.REP,
send_email: config.sendEmail
};
// Determinar tipo de movimiento basado en checkboxes adicionales
let movementType: any = 'ALL';
if (types.export.additional.AFIJO) movementType = 'AFIJO';
else if (types.export.additional.NODES) movementType = 'NODES';
else if (types.export.additional.SCRAP) movementType = 'SCRAP';
else if (types.export.additional.REEXP) movementType = 'REEXP';
else if (types.export.additional.DONAC) movementType = 'DONAC';
else if (types.other.VEMEX) movementType = 'VEMEX';
// Override flags if "TODAS" is selected
if (types.other.TODAS) {
allMovementsFilter.import_temp = true;
allMovementsFilter.import_def = true;
allMovementsFilter.import_rep = true;
allMovementsFilter.export_def = true;
allMovementsFilter.export_rep = true;
allMovementsFilter.operation_type = null;
} else if (types.export.additional.TODAS) {
allMovementsFilter.import_temp = false;
allMovementsFilter.import_def = false;
allMovementsFilter.import_rep = false;
allMovementsFilter.export_def = true;
allMovementsFilter.export_rep = true;
allMovementsFilter.operation_type = 'exp';
} else {
const hasImports =
allMovementsFilter.import_temp ||
allMovementsFilter.import_def ||
allMovementsFilter.import_rep;
const hasExports = allMovementsFilter.export_def || allMovementsFilter.export_rep;
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getExports({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter,
use_transport_method: false
})
: await invoiceMovementsApi.getExportsDetailed({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter,
use_transport_method: false
});
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) allResults.push(...response.data);
if (hasImports && hasExports) {
allMovementsFilter.operation_type = null;
} else if (hasImports) {
allMovementsFilter.operation_type = 'imp';
} else if (hasExports) {
allMovementsFilter.operation_type = 'exp';
}
// Exportaciones de Reparación
if (types.export.main.REP || types.export.additional.TODAS) {
toast.info('Obteniendo exportaciones de reparación...');
// Para reparaciones solo aplican AFIJO y NODES
let movementType: any = 'ALL';
if (types.export.additional.AFIJO) movementType = 'AFIJO';
else if (types.export.additional.NODES) movementType = 'NODES';
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getExportRepairs({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter
})
: await invoiceMovementsApi.getExportRepairsDetailed({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter
});
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) allResults.push(...response.data);
}
// Cambio de Régimen (CREG) - Exportaciones con cambio de régimen
if (types.export.main.CREG) {
toast.info('Obteniendo cambios de régimen...');
// Para cambio de régimen solo aplican AFIJO y SCRAP
let movementType: any = 'ALL';
if (types.export.additional.AFIJO) movementType = 'AFIJO';
else if (types.export.additional.SCRAP) movementType = 'SCRAP';
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getExports({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter,
use_transport_method: false
})
: await invoiceMovementsApi.getExportsDetailed({
...baseFilter,
database_name: 'default',
movement_type: movementType,
discharge_filter: dischargeFilter,
use_transport_method: false
});
if (response.data) allResults.push(...response.data);
}
// CREGEXP (Cambio Régimen Export) - Caso especial
if (types.other.CREGEXP) {
toast.info('Obteniendo cambios de régimen export...');
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
const response =
config.reportType === 'normal'
? await invoiceMovementsApi.getExports({
...baseFilter,
database_name: 'default',
movement_type: 'ALL',
discharge_filter: dischargeFilter,
use_transport_method: false
})
: await invoiceMovementsApi.getExportsDetailed({
...baseFilter,
database_name: 'default',
movement_type: 'ALL',
discharge_filter: dischargeFilter,
use_transport_method: false
});
if (response.error) {
toast.error(response.error);
return;
}
if (response.data) allResults.push(...response.data);
}
results = allResults;
showResults = true;
// Ordenar resultados según configuración y modo de reporte
if (config.reportType === 'normal') {
// LLENADOCSVNORMAL - SORT con 3 campos
if (config.shelter) {
results.sort((a, b) => {
if (a.BaseDeDatos !== b.BaseDeDatos) return a.BaseDeDatos.localeCompare(b.BaseDeDatos);
if (a.TipoMovTemDef !== b.TipoMovTemDef)
return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef);
return (a.FechaFactura || '').localeCompare(b.FechaFactura || '');
});
} else {
results.sort((a, b) => {
if (a.TipoMovTemDef !== b.TipoMovTemDef)
return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef);
if ((a.FechaFactura || '') !== (b.FechaFactura || ''))
return (a.FechaFactura || '').localeCompare(b.FechaFactura || '');
return a.BaseDeDatos.localeCompare(b.BaseDeDatos);
});
}
} else {
// LLENADOCSVDETALLADO - SORT más simple
if (config.shelter) {
results.sort((a, b) => {
if (a.BaseDeDatos !== b.BaseDeDatos) return a.BaseDeDatos.localeCompare(b.BaseDeDatos);
if (a.TipoMovTemDef !== b.TipoMovTemDef)
return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef);
return (a.FechaFactura || '').localeCompare(b.FechaFactura || '');
});
} else {
results.sort((a, b) => {
if (a.TipoMovTemDef !== b.TipoMovTemDef)
return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef);
return (a.FechaFactura || '').localeCompare(b.FechaFactura || '');
});
}
}
// Configurar título del reporte
reportTitle = 'REPORTE DE FACTURAS';
// Configurar etiqueta de moneda
if (config.currency === 'foreign') {
currencyLabel = 'Moneda: Dólares';
} else if (config.currency === 'national') {
currencyLabel = 'Moneda: Pesos';
} else {
currencyLabel = 'Moneda: Captura';
}
if (allResults.length === 0) {
toast.warning('No se encontraron resultados con los filtros seleccionados');
} else {
toast.success(`Reporte generado exitosamente: ${allResults.length} registros encontrados`);
}
} catch (error: any) {
console.error('Error generando reporte:', error);
toast.error(error.message || 'Error al generar el reporte');
} finally {
loading = false;
}
// Set discharge filter (Global)
const dischargeFilter =
filters.downloaded === 'downloaded'
? 'SiDes'
: filters.downloaded === 'not_downloaded'
? 'NoDes'
: 'ALL';
allMovementsFilter.discharge_filter = dischargeFilter;
return allMovementsFilter;
}
async function handleGenerateReport() {
// Wrapper for compatibility if button still calls this
await generateReport();
}
function downloadCSV() {
@@ -1171,11 +1006,31 @@
<div class="flex items-center gap-3">
<div class="flex-1 space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Fecha Inicio</Label>
<Input type="date" class="h-8" bind:value={dates.from} />
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.from}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') {
input.showPicker();
}
}}
/>
</div>
<div class="flex-1 space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Fecha Fin</Label>
<Input type="date" class="h-8" bind:value={dates.to} />
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.to}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') {
input.showPicker();
}
}}
/>
</div>
</div>
@@ -1517,7 +1372,8 @@
<Button
class="h-9 flex-1 text-sm shadow-sm"
size="default"
onclick={handleGenerateReport}
onclick={generateReport}
oncontextmenu={handleContextMenu}
disabled={loading}
>
{#if loading}
@@ -1860,3 +1716,25 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<svelte:window onclick={closeContextMenu} />
<!-- Manual Context Menu -->
{#if contextMenu.open}
<div
class="animate-in fade-in-80 fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style="top: {contextMenu.y}px; left: {contextMenu.x}px;"
>
<button
class="relative flex w-full cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
previewReport();
}}
>
<Eye class="mr-2 h-4 w-4" />
Ver vista previa
</button>
</div>
{/if}