feat: Implement asynchronous invoice report generation with email delivery and frontend status polling.
This commit is contained in:
@@ -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 ''
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal file
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
117
backend/core/email.py
Normal 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
|
||||
@@ -50,4 +50,4 @@ redis==5.0.1
|
||||
flower==2.0.1
|
||||
|
||||
# Barcode
|
||||
pdf417gen==0.8.1
|
||||
pdf417gen==0.8.1asgiref==3.8.1
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user