From 47c4984d3f94875b2033fbf171c5bc3980467bc5 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 29 Jan 2026 08:56:25 -0600 Subject: [PATCH] Se integro la plantilla de aviso de consolidado --- .../a76/items/line_quantities/schemas.py | 3 +- .../a76/reports/exportacion/__init__.py | 0 .../exportacion/aviso_consolidado/__init__.py | 0 .../exportacion/aviso_consolidado/routes.py | 46 +++ .../exportacion/aviso_consolidado/service.py | 305 +++++++++++++++ .../exportacion/aviso_consolidado/task.py | 50 +++ .../templates/avcon_exp.html | 357 ++++++++++++++++++ .../importacion/facturas/mex/service.py | 2 +- backend/api/v1/modules/a76/router.py | 7 + backend/core/celery_app.py | 3 +- backend/core/error_handlers.py | 3 +- .../a76/reports/reports-aviso-consolidado.ts | 35 ++ .../routes/dashboard/invoices/+page.svelte | 34 +- 13 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/exportacion/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts diff --git a/backend/api/v1/modules/a76/items/line_quantities/schemas.py b/backend/api/v1/modules/a76/items/line_quantities/schemas.py index 71552342..3050551e 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/schemas.py +++ b/backend/api/v1/modules/a76/items/line_quantities/schemas.py @@ -27,9 +27,8 @@ class LineQuantityBase(BaseModel): gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)") # Packaging - package_key: Optional[str] = Field(None, max_length=5, description="Package key (CLAVEBULTOS)") + package_id: Optional[int] = Field(None, description="Package ID (GBultos)") package_quantity: Optional[int] = Field(None, description="Package quantity (CANTBULTOS)") - package_description: Optional[str] = Field(None, max_length=40, description="Package description (DESCBULTOS)") container_quantity: Optional[int] = Field(None, description="Container quantity (CANTBULCONT)") container_description: Optional[str] = Field(None, max_length=40, description="Container description (DESCCONTENEDOR)") box_count: Optional[str] = Field(None, max_length=30, description="Box count (NOCAJAS)") diff --git a/backend/api/v1/modules/a76/reports/exportacion/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py new file mode 100644 index 00000000..09cf878f --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py @@ -0,0 +1,46 @@ + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from celery.result import AsyncResult +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .task import generar_pdf_aviso_consolidado_exp_async + +router = APIRouter() + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "state": task_result.state, + "result": None, + "info": None + } + + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info + + return response + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_aviso_consolidado_exp( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + task = generar_pdf_aviso_consolidado_exp_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py new file mode 100644 index 00000000..a85c0c82 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -0,0 +1,305 @@ + +import shutil +import base64 +import pdfkit +from pathlib import Path +from typing import Tuple, List, Callable, Optional, Dict, Any +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel +from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms + +# --- SCHEMAS FOR TEMPLATE CONTEXT --- +class EmpresaSchema(BaseModel): + rfc: str + razon_social: str + direccion_completa: str + tax_id: Optional[str] = None # Extra info just in case + +class PersonaSchema(BaseModel): + nombre: str + rfc: str + curp: str + +class AvisoSchema(BaseModel): + pedimento_completo: str + tipo_operacion: str + clave_pedimento: str + acus_valor: str + aduana_seccion: str + numero_remesa: str + peso_bruto: str + codigo_aceptacion: str + codigo_barras_b64: Optional[str] = None + clave_seccion: str + marcas_numeros_bultos: str + candados: List[str] + vehiculo_placas: str + vehiculo_tipo: str + observaciones: str + numero_certificado: str + tipo_documento: str # NEW: Invoice Type + firma_electronica: str + +class AvisoConsolidadoContext(BaseModel): + aviso: AvisoSchema + empresa: EmpresaSchema + agente: PersonaSchema + mandatario: PersonaSchema + +class AvisoConsolidadoExportacionService: + def __init__(self): + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('avcon_exp.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> AvisoConsolidadoContext: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + + # Fetch minimal real data if possible, or use placeholders as requested + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: + # We can't strictly raise 404 if we want to support testing with non-existent IDs for pure UI check, + # but valid workflow requires a real invoice. Raising 404 is better practice. + raise HTTPException(status_code=404, detail="Factura no encontrada") + + company = db.query(Company).filter(Company.id == company_id).first() + + if progress_callback: progress_callback(30, "Preparando datos...") + + # --- FETCHING REAL DATA --- + + # 1. Compliance & Pedimento + compliance = header.compliance_mx + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + # Pedimento Completo Construction + pedimento_txt = "S/P" + clave_ped = "" + if pedimento: + # Format: YY OFF LIC NUMBER + year = pedimento.year or "" + office = pedimento.customs_office or "" + lic = pedimento.license or "" + num = pedimento.pedimento_number or "" + pedimento_txt = f"{year} {office} {lic} {num}" + clave_ped = pedimento.pedimento_code or "" + + # 2. Company Address + direccion_empresa = "DOMICILIO NO REGISTRADO" + if company and company.addresses: + # Try to find fiscal address or first available + addr = company.addresses[0] # Default + # TODO: Check if there's a specific flag for fiscal address in submodel + + parts = [] + if addr.street: parts.append(addr.street) + if addr.exterior_number: parts.append(f"No. {addr.exterior_number}") + if addr.interior_number: parts.append(f"Int. {addr.interior_number}") + if addr.neighborhood: parts.append(f"Col. {addr.neighborhood}") + if addr.postal_code: parts.append(f"CP {addr.postal_code}") + if addr.city: parts.append(addr.city) + if addr.state: parts.append(addr.state) + if addr.country: parts.append(addr.country) + + if parts: + direccion_empresa = ", ".join(parts).upper() + + # Determine Mexican Entity based on Operation Type + # IMP -> Client (Sold To/Consignee) + # EXP -> Company (Tenant) + + target_entity_data = { + "rfc": getattr(company, 'rfc', "") or "", + "razon_social": getattr(company, 'name', "") or "", + "direccion_completa": direccion_empresa + } + + op_type = header.operation_type.upper() if header.operation_type else "EXP" + + if op_type == "IMP" and compliance and compliance.sold_to_id: + # Fetch Client Data + client_id = compliance.sold_to_id + client_obj = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if client_obj: + # Fetch Address + c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + # Fetch Fiscal Data (RFC) + c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + c_rfc = "" + if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id + elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc + + c_dir_str = "DOMICILIO NO REGISTRADO" + if c_addr: + parts_c = [] + if c_addr.streets: parts_c.append(c_addr.streets) + if c_addr.exterior_number: parts_c.append(f"No. {c_addr.exterior_number}") + if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}") + if c_addr.city: parts_c.append(c_addr.city) + if c_addr.state: parts_c.append(c_addr.state) + if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}") + if parts_c: + c_dir_str = ", ".join(parts_c).upper() + + target_entity_data = { + "rfc": c_rfc or "", + "razon_social": client_obj.name or client_obj.short_name or "", + "direccion_completa": c_dir_str + } + + empresa = EmpresaSchema( + rfc=target_entity_data["rfc"], + razon_social=target_entity_data["razon_social"], + direccion_completa=target_entity_data["direccion_completa"] + ) + + + + # 3. Datos Aviso (Invoice/Compliance/Logistics/Financials) + financials = header.financials + logistics = header.logistics + + # Peso Bruto + peso_bruto_val = "0.0" + if financials and financials.gross_weight: + peso_bruto_val = f"{financials.gross_weight:,.2f}" + elif pedimento and pedimento.gross_weight: + peso_bruto_val = f"{pedimento.gross_weight:,.2f}" + + # Candados (Seals) + candados_list = [] + if logistics and logistics.seal_number: + # Split by comma or space if multiple + candados_list = [s.strip() for s in logistics.seal_number.replace(',', ' ').split() if s.strip()] + + # Vehiculo + placas_val = "" + tipo_veh_val = "" + if logistics: + placas_val = logistics.license_plate or logistics.vehicle_num or logistics.trailer_num or "" + tipo_veh_val = logistics.transport_type or "" + + aviso = AvisoSchema( + pedimento_completo=pedimento_txt, + tipo_operacion=header.operation_type.upper() if header.operation_type else "EXP", + clave_pedimento=clave_ped, + acus_valor=compliance.edocument if (compliance and compliance.edocument) else "", + aduana_seccion=compliance.aduana if (compliance and compliance.aduana) else "", + numero_remesa=str(compliance.remesa) if (compliance and compliance.remesa) else "", + peso_bruto=peso_bruto_val, + codigo_aceptacion="", # TODO: Clarify source. Using empty for now or Edocument? + codigo_barras_b64=None, + clave_seccion=compliance.aduana if (compliance and compliance.aduana) else "", # Using Aduana as Section Key + marcas_numeros_bultos=f"{financials.bundle_count} BULTOS" if (financials and financials.bundle_count) else "1 BULTOS", + candados=candados_list, + vehiculo_placas=placas_val, + vehiculo_tipo=tipo_veh_val, + observaciones=header.observation_es or "", + numero_certificado=compliance.certificate_number if (compliance and compliance.certificate_number) else "", + tipo_documento=header.document_type or "FACTURA", # Default + firma_electronica=compliance.electronic_signature if (compliance and compliance.electronic_signature) else "" + ) + + # 4. Agente Aduanal + nombre_agente = "" + rfc_agente = "" + curp_agente = "" + + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: + nombre_agente = broker.name or "" + rfc_agente = broker.tax_id or "" + curp_agente = broker.personal_id or "" + + agente = PersonaSchema( + nombre=nombre_agente, + rfc=rfc_agente, + curp=curp_agente + ) + + # 5. Mandatario (CustomsBrokerPersonnel) + mandatario = PersonaSchema(nombre="", rfc="", curp="") + + if broker: + # Try to find personnel associated with this broker + # Using direct query to ensure specific order if needed, typically just the first valid one + personnel = db.query(CustomsBrokerPersonnel).filter( + CustomsBrokerPersonnel.customs_broker_id == broker.id + ).first() + + if personnel: + # Construct name if main field is empty + full_name = personnel.name + if not full_name: + parts = [] + if personnel.first_name: parts.append(personnel.first_name) + if personnel.last_name: parts.append(personnel.last_name) + if personnel.middle_name: parts.append(personnel.middle_name) + full_name = " ".join(parts) + + mandatario = PersonaSchema( + nombre=full_name or "", + rfc=personnel.tax_id or "", + curp=personnel.personal_id or "" + ) + + return AvisoConsolidadoContext( + aviso=aviso, + empresa=empresa, + agente=agente, + mandatario=mandatario + ) + + except Exception as e: + print(f"Error Service A76 Export Aviso Consolidado: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def generar_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + context = datos.model_dump() + html_content = self.template.render(**context) + nombre = f"AvisoConsolidado_Exp_{invoice_id}.pdf" + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = { + 'page-size': 'Letter', + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8", + 'enable-local-file-access': None + } + + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py new file mode 100644 index 00000000..651919d4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -0,0 +1,50 @@ + +import base64 +import logging +from core.celery_app import celery_app +from core.database import CoreSessionLocal + +from .service import AvisoConsolidadoExportacionService + +logger = logging.getLogger(__name__) + +@celery_app.task(name="generar_pdf_aviso_consolidado_exp_async", bind=True) +def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: int): + # 1. Abrimos conexión a la DB + db = CoreSessionLocal() + try: + logger.info(f"Worker procesando Aviso Consolidado Exp {invoice_id}...") + + # 2. Instanciamos el servicio + service = AvisoConsolidadoExportacionService() + + # Update state to PROCESSING + self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) + + def progress_callback(progress: int, status: str): + self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) + + # 3. Generamos los bytes del PDF + pdf_bytes, nombre, media_type = service.generar_pdf( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=progress_callback + ) + + # 4. Codificamos a base64 + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": nombre, + "content": pdf_base64, + "media_type": media_type + } + + except Exception as e: + logger.error(f"Error en Celery Worker Aviso Consolidado Exp: {str(e)}") + return {"status": "error", "message": str(e)} + + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html new file mode 100644 index 00000000..64a41575 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html @@ -0,0 +1,357 @@ + + + + + + Aviso Consolidado - {{ aviso.pedimento_completo }} + + + + + + + + + +
+

AVISO CONSOLIDADO

+
+

Página 1 de 1

+
+ + + + + + + + +
+ NUM. PEDIMENTO: + {{ aviso.pedimento_completo }} + + T. OPER: + {{ aviso.tipo_operacion }} + + CVE. PEDIMENTO: + {{ aviso.clave_pedimento }} + + CERTIFICACIONES +
+ TIPO: {{ aviso.tipo_documento }} +
+ + + + + + + + + + + + + + + + + +
+ NUMERO DE ACUSE DE VALOR: + {{ aviso.acus_valor }} + +

 

+
+ ADUANA E/S: + {{ aviso.aduana_seccion }} + + NUM. REMESA: + {{ aviso.numero_remesa }} + + PESO BRUTO: + {{ aviso.peso_bruto }} +
DATOS DEL IMPORTADOR/EXPORTADOR
+
+

RFC:

+

{{ empresa.rfc }}

+
+
+

NOMBRE, DENOMINACION O RAZON SOCIAL:

+

{{ empresa.razon_social }}

+

{{ empresa.direccion_completa }}

+
+
+ + + + + + + +
+

CODIGO DE ACEPTACION:

+

{{ aviso.codigo_aceptacion }}

+
+

CODIGO DE BARRAS

+
+ {% if aviso.codigo_barras_b64 %} + + {% else %} +


+ {% endif %} +
+
+

CLAVE DE LA SECCION ADUANERA DE DESPACHO:

+

{{ aviso.clave_seccion }}

+
+ + + + + + + + +
MARCAS, NUMEROS Y TOTAL DE BULTOS: +
+

{{ aviso.marcas_numeros_bultos }}

+
+ + + + + + + + + + +
NUMERO DE CANDADO: + {{ aviso.candados[0] if aviso.candados|length > 0 }}{{ aviso.candados[1] if aviso.candados|length > 1 }}{{ aviso.candados[2] if aviso.candados|length > 2 }}{{ aviso.candados[3] if aviso.candados|length > 3 }}{{ aviso.candados[4] if aviso.candados|length > 4 }}
+ + + + + + + + + + + + + + +
1RA. REVISION +
2DA. REVISION +
+ + + + + + + + +
NUMERO/TIPO:{{ aviso.vehiculo_placas }}{{ aviso.vehiculo_tipo }}
+ + + + + + + + +
OBSERVACIONES
+

{{ aviso.observaciones }}

+
+ + + + + +
+

AGENTE ADUANAL, APODERADO ADUANAL:

+ +
+ NOMBRE: + {{ agente.nombre }} +
+ +
+
+ RFC: + {{ agente.rfc }} +
+
+ CURP: + {{ agente.curp }} +
+
+ +
+ MANDATARIO/PERSONA AUTORIZADA: +
+ +
+ NOMBRE: + {{ mandatario.nombre }} +
+ +
+
+ RFC: + {{ mandatario.rfc }} +
+
+ CURP: + {{ mandatario.curp }} +
+
+ +
+ NUMERO DE SERIE DEL CERTIFICADO: + {{ aviso.numero_certificado }} +
+
+ e.firma: +

{{ aviso.firma_electronica }}

+
+
+ +

*********************************************************************** FIN DE LA + IMPRESION ***********************************************************************

+ + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..dee88bdd 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -316,7 +316,7 @@ class FacturaImportacionMexService: cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), unidad_medida=qty.weight_unit if qty else "PZA", cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", + clave_bultos=(qty.package_info.key if (qty and qty.package_info and qty.package_info.key) else "PZA"), peso_neto=self.formatear_numero(qty.net_weight if qty else 0), peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), valor_costo_unitario=self.formatear_numero(v_unitario), diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 6da0ba71..01c8c57d 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -52,6 +52,7 @@ from api.v1.modules.public.reference_data.material_types.routes import router as from .reports.importacion.facturas.routes import router as invoices_reports_router from .reports.importacion.consolidados.routes import router as consolidated_reports_router from .reports.importacion.packing_list.routes import router as packing_list_router +from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router @@ -138,4 +139,10 @@ router.include_router( packing_list_router, prefix="/a76/reports/importacion/packing-lists", tags=["a76 / reports"] +) + +router.include_router( + aviso_consolidado_export_router, + prefix="/a76/reports/exportacion/aviso_consolidado", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index bc8bd492..3e41aa5f 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -11,7 +11,8 @@ celery_app = Celery( include=[ "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.importacion.packing_list.task", + "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task" ] # Ruta al módulo donde están las tareas ) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 4fe62025..e1f7783a 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -7,6 +7,7 @@ from typing import Any, Dict from fastapi import Request, status from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -37,7 +38,7 @@ async def base_exception_handler( return JSONResponse( status_code=exc.status_code, - content=exc.to_dict(), + content=jsonable_encoder(exc.to_dict()), ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts new file mode 100644 index 00000000..363b1daf --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts @@ -0,0 +1,35 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const avisoConsolidadoReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación del Aviso Consolidado'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado del Aviso Consolidado'); + return await response.json(); + } +}; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index e4e04619..146cd50b 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -372,6 +372,8 @@ } } + import { avisoConsolidadoReportsApi } from '$lib/api/dashboard/a76/reports/reports-aviso-consolidado'; + async function handleDownloadConsolidated(invoice: any) { if (!companyStore.activeCompany) { toast.error("No hay empresa seleccionada"); @@ -379,7 +381,7 @@ } try { - // 1. Trigger: Iniciar la tarea en Celery (Consolidado) + // 1. Trigger: Iniciar la tarea en Celery (Consolidado Importación) const { task_id } = await consolidatedReportsApi.triggerPdfGeneration( invoice.id, companyStore.activeCompany.id @@ -394,7 +396,30 @@ console.error(error); toast.error("No se pudo iniciar la descarga del consolidado"); } + } + async function handleDownloadAvisoConsolidado(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Iniciar la tarea en Celery (Aviso Consolidado Exportación) + const { task_id } = await avisoConsolidadoReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Abrir diálogo de progreso + currentTaskId = task_id; + currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del Aviso Consolidado"); + } } async function handleDownloadPackingList(invoice: any) { @@ -655,10 +680,17 @@ Factura + + + +