From f3f763cd6da4c78259e43faac8c71edee91d60a0 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 6 Feb 2026 15:31:30 -0600 Subject: [PATCH] feat: Implement asynchronous invoice report generation with email delivery and frontend status polling. --- .../reports/movements/invoices/csv_utils.py | 87 +++ .../movements/invoices/movement_service.py | 35 +- .../a76/reports/movements/invoices/routes.py | 102 ++- .../a76/reports/movements/invoices/schemas.py | 41 ++ .../invoices/services/query_builders.py | 55 +- .../movements/invoices/services/temporary.py | 4 +- .../a76/reports/movements/invoices/tasks.py | 108 +++ backend/api/v1/modules/a76/router.py | 1 - backend/core/celery_app.py | 3 +- backend/core/config.py | 8 + backend/core/email.py | 117 ++++ backend/requirements.txt | 2 +- .../api/dashboard/a76/invoice-movements.ts | 20 +- .../dashboard/reports/invoices/+page.svelte | 614 +++++++----------- 14 files changed, 797 insertions(+), 400 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/tasks.py create mode 100644 backend/core/email.py 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 new file mode 100644 index 00000000..004b7ae4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -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 '' diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py index 1d3cc18b..41af2b37 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -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, 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 21eff0cc..0d406f07 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py index 3f4ede62..002331cf 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -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": { diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py index 23a9ae23..86bf2f1a 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -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), diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py index 70fa5ca5..474f6d07 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py new file mode 100644 index 00000000..a2de6aef --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -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() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 8a0ff1f8..7b89d8b8 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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() diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 3e41aa5f..235149f8 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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 ) diff --git a/backend/core/config.py b/backend/core/config.py index 37c5d52f..b44417d4 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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, diff --git a/backend/core/email.py b/backend/core/email.py new file mode 100644 index 00000000..41b81a67 --- /dev/null +++ b/backend/core/email.py @@ -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""" + + +
+

+ Reporte de Facturas - Sistema Anexo76 +

+

{body_text}

+

+ El reporte se encuentra adjunto en formato CSV. +

+
+

+ Este es un correo generado automáticamente. Por favor no responder. +

+

+ Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')} +

+
+ + + """ + 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 diff --git a/backend/requirements.txt b/backend/requirements.txt index c169f2ca..d21c8780 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,4 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1 \ No newline at end of file +pdf417gen==0.8.1asgiref==3.8.1 diff --git a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts index ce6daa22..5424bc4b 100644 --- a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -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('/v1/a76/reports/movements/invoices/all', filters) + api.post('/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}` + ) }; diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 6c3083ad..33bc86c2 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -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 = { - 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 @@
- + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + />
- + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + />
@@ -1517,7 +1372,8 @@ + +{/if}