From e8db8f2a8656b348e1bae540ad8bc2d030c11657 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Apr 2026 07:42:02 -0600 Subject: [PATCH] bak --- .../a76/factura_cove/report_service.py | 577 ++++++++++++++++++ .../api/v1/modules/a76/factura_cove/routes.py | 101 ++- .../factura_cove/templates/acuse_cove.html | 452 ++++++++++++++ backend/core/s3_keys.py | 13 + .../src/lib/api/dashboard/a76/invoices.ts | 7 + .../routes/dashboard/invoices/+page.svelte | 86 ++- 6 files changed, 1220 insertions(+), 16 deletions(-) create mode 100644 backend/api/v1/modules/a76/factura_cove/report_service.py create mode 100644 backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html diff --git a/backend/api/v1/modules/a76/factura_cove/report_service.py b/backend/api/v1/modules/a76/factura_cove/report_service.py new file mode 100644 index 00000000..dc2de0a0 --- /dev/null +++ b/backend/api/v1/modules/a76/factura_cove/report_service.py @@ -0,0 +1,577 @@ +""" +Generación de PDF de Acuse de COVE (Jinja2 + pdfkit / wkhtmltopdf). + +El contexto se construye preferentemente a partir del XML de COVE +almacenado en S3 (cuando está disponible y coincide el e-document), +y en su defecto directamente desde la base de datos reutilizando +la lógica ya existente en FacturaCoveDomainService (_build_personas, +_build_mercancias). + +Secciones del reporte (derivadas del Report REPORT legacy en Clarion): + - Cabecera: tipo_operacion, numero_factura, tipo_figura, fecha_expedicion + - Observaciones + - RFC de consulta / patente aduanal + - Datos del proveedor/emisor (nombre, identificación, domicilio completo) + - Datos del destinatario (nombre, identificación, domicilio completo) + - Por cada partida: descripción genérica de la mercancía, clave UMC, + cantidad, tipo moneda, valor unitario, valor total, + valor en dólares + - Por cada partida: marca, modelo, serie, submodelo (DescripcionEspecifica) + - Footer +""" + +from __future__ import annotations + +import logging +import os +import shutil +from dataclasses import dataclass, field +from decimal import Decimal +from pathlib import Path +from typing import Any, List, Optional +from xml.etree import ElementTree as ET + +from core import storage_s3 +import pdfkit +from jinja2 import Environment, FileSystemLoader, select_autoescape +from sqlalchemy.orm import Session + +from core.exceptions import ValidationException + +from .schemas import MercanciaCove, PersonaCove +from .service import FacturaCoveDomainService + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Modelos de vista para el template (uno por sección del reporte legacy) +# --------------------------------------------------------------------------- + +@dataclass +class CoveItemView: + """Corresponde a DetallePartidasFactura + DetallePartidaMarcaModelo del legacy.""" + # DetallePartidasFactura + descripcion_generica: str + clave_unidad_medida: str + cantidad: str + tipo_moneda: str + valor_unitario: str + valor_total: str + valor_dolares: str + # DetallePartidaMarcaModelo (DescripcionEspecifica) + descripciones_especificas: List["CoveDescEspecificaView"] = field(default_factory=list) + + +@dataclass +class CoveDescEspecificaView: + marca: str = "" + modelo: str = "" + submodelo: str = "" + numero_serie: str = "" + + +@dataclass +class CovePersonaView: + """Corresponde al bloque Emisor/Destinatario del reporte legacy.""" + # Loc:TipoIdentificador / Loc:Des_TipoIdentificador + tipo_identificador: str = "" + # Loc:identificacion / Loc:Des_identificacion + identificacion: str = "" + # Loc:Emisor_Nombre / Loc:Des_Nombre + nombre: str = "" + apellido_paterno: str = "" + apellido_materno: str = "" + # Domicilio + calle: str = "" + numero_exterior: str = "" + numero_interior: str = "" + colonia: str = "" + codigo_postal: str = "" + localidad: str = "" + municipio: str = "" + entidad_federativa: str = "" + pais: str = "" + + +@dataclass +class CoveAcuseContext: + """ + Contexto completo para el template acuse_cove.html. + + Mapeo de campos Clarion → atributos de este dataclass: + Loc:tipoOperacion → tipo_operacion + Loc:numeroFacturaOriginal → numero_factura + Loc:tipoFigura → tipo_figura + Loc:fechaExpedicion → fecha_expedicion + Loc:ObservacionesCove → observaciones + Loc:rfcConsulta → rfc_consulta + Loc:patenteAduanal → patente_aduanal + Emisor.* → emisor (CovePersonaView) + Destinatario.* → destinatario (CovePersonaView) + DetallePartidasFactura.* → items (lista de CoveItemView) + """ + # --- Sección "Datos" (cabecera) --- + tipo_operacion: str = "" + numero_factura: str = "" + tipo_figura: str = "" + fecha_expedicion: str = "" + observaciones: str = "" + rfc_consulta: str = "" + patente_aduanal: str = "" + edocument: str = "" + vucem_operation_num: str = "" + # --- Personas --- + emisor: CovePersonaView = field(default_factory=CovePersonaView) + destinatario: CovePersonaView = field(default_factory=CovePersonaView) + # --- Partidas --- + items: List[CoveItemView] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Helpers de conversión +# --------------------------------------------------------------------------- + +def _fmt_decimal(value: Decimal | None) -> str: + if value is None: + return "0.00" + return f"{value:,.2f}" + + +def _persona_to_view(p: PersonaCove) -> CovePersonaView: + return CovePersonaView( + tipo_identificador=p.tipo_identificador or "", + identificacion=p.identificacion or "", + nombre=p.nombre or "", + apellido_paterno=p.apellido_paterno or "", + apellido_materno=p.apellido_materno or "", + calle=p.calle or "", + numero_exterior=p.numero_exterior or "", + numero_interior=p.numero_interior or "", + colonia=p.colonia or "", + codigo_postal=p.codigo_postal or "", + localidad=p.localidad or "", + municipio=p.municipio or "", + entidad_federativa=p.entidad_federativa or "", + pais=p.pais or "", + ) + + +def _mercancia_to_view(m: MercanciaCove) -> CoveItemView: + descripciones = [ + CoveDescEspecificaView( + marca=d.marca or "", + modelo=d.modelo or "", + submodelo=d.submodelo or "", + numero_serie=d.numero_serie or "", + ) + for d in (m.descripcion_especifica or []) + ] + return CoveItemView( + descripcion_generica=m.descripcion_generica or "", + clave_unidad_medida=m.clave_unidad_medida or "", + cantidad=_fmt_decimal(m.cantidad), + tipo_moneda=m.tipo_moneda or "", + valor_unitario=_fmt_decimal(m.valor_unitario), + valor_total=_fmt_decimal(m.valor_total), + valor_dolares=_fmt_decimal(m.valor_dolares), + descripciones_especificas=descripciones, + ) + + +def _strip_tag(tag: str) -> str: + """ + Normaliza un tag XML removiendo el namespace ({...}) y llevándolo a minúsculas. + """ + if "}" in tag: + tag = tag.split("}", 1)[1] + return tag.strip().lower() + + +def _find_first_text(elem: ET.Element, candidates: list[str]) -> str: + """ + Busca recursivamente el primer elemento cuyo nombre coincida con alguno + de los candidatos (ignorando namespace y mayúsculas) y devuelve su texto. + """ + wanted = {c.lower() for c in candidates} + for node in elem.iter(): + if _strip_tag(node.tag) in wanted: + return (node.text or "").strip() + return "" + + +def _find_child_text(elem: ET.Element, candidates: list[str]) -> str: + """ + Busca solo entre los hijos directos de elem. + """ + wanted = {c.lower() for c in candidates} + for node in list(elem): + if _strip_tag(node.tag) in wanted: + return (node.text or "").strip() + return "" + + +def _build_persona_from_xml(root: ET.Element, role_candidates: list[str]) -> CovePersonaView: + """ + Intenta construir una CovePersonaView desde un bloque o + (o etiquetas equivalentes) en el XML de COVE. + + Este mapeo es heurístico y se basa en los nombres de campos del manual COVE + y del código legacy (Loc:Emisor_*, Loc:Des_*). + """ + role_elems: list[ET.Element] = [] + wanted_roles = {r.lower() for r in role_candidates} + for node in root.iter(): + if _strip_tag(node.tag) in wanted_roles: + role_elems.append(node) + break + + if not role_elems: + return CovePersonaView() + + e = role_elems[0] + return CovePersonaView( + tipo_identificador=_find_child_text(e, ["tipoIdentificador", "tipo_identificador"]), + identificacion=_find_child_text(e, ["identificador", "taxId", "rfc", "sinTaxId"]), + nombre=_find_child_text(e, ["nombre", "razonSocial", "razon_social"]), + apellido_paterno=_find_child_text(e, ["apellidoPaterno", "apellido_paterno"]), + apellido_materno=_find_child_text(e, ["apellidoMaterno", "apellido_materno"]), + calle=_find_child_text(e, ["calle"]), + numero_exterior=_find_child_text(e, ["numeroExterior", "numero_exterior"]), + numero_interior=_find_child_text(e, ["numeroInterior", "numero_interior"]), + colonia=_find_child_text(e, ["colonia"]), + codigo_postal=_find_child_text(e, ["codigoPostal", "codigo_postal"]), + localidad=_find_child_text(e, ["localidad"]), + municipio=_find_child_text(e, ["municipio"]), + entidad_federativa=_find_child_text(e, ["entidadFederativa", "entidad_federativa", "estado"]), + pais=_find_child_text(e, ["pais"]), + ) + + +def _build_items_from_xml(root: ET.Element) -> list[CoveItemView]: + """ + Construye una lista de CoveItemView a partir de los nodos + (o equivalentes) del XML COVE. + """ + items: list[CoveItemView] = [] + + # Encontrar todos los nodos tipo "mercancia" + for node in root.iter(): + if _strip_tag(node.tag) not in {"mercancia", "mercancias", "item", "partida"}: + continue + + # Evitar interpretar el contenedor como ítem + # (si tiene hijos lo tratamos como contenedor). + child_tags = {_strip_tag(c.tag) for c in list(node)} + if "mercancia" in child_tags: + continue + + desc_generica = _find_child_text(node, ["descripcionGenerica", "descripcion_generica", "descripcion"]) + clave_uom = _find_child_text(node, ["claveUnidadMedida", "clave_unidad_medida", "claveUmed"]) + cantidad_txt = _find_child_text(node, ["cantidad"]) + tipo_moneda = _find_child_text(node, ["tipoMoneda", "tipo_moneda", "moneda"]) + valor_unit_txt = _find_child_text(node, ["valorUnitario", "valor_unitario"]) + valor_total_txt = _find_child_text(node, ["valorTotal", "valor_total"]) + valor_usd_txt = _find_child_text(node, ["valorDolares", "valor_dolares", "valorUsd", "valor_usd"]) + + def _to_decimal(text: str) -> Decimal: + try: + return Decimal(text.replace(",", "")) if text else Decimal("0") + except Exception: + return Decimal("0") + + cantidad = _to_decimal(cantidad_txt).quantize(Decimal("0.01")) + valor_unitario = _to_decimal(valor_unit_txt).quantize(Decimal("0.01")) + valor_total = _to_decimal(valor_total_txt).quantize(Decimal("0.01")) + valor_dolares = _to_decimal(valor_usd_txt).quantize(Decimal("0.01")) + + # Descripciones específicas (marca/modelo/serie/submodelo) + desc_especificas: list[CoveDescEspecificaView] = [] + for de in node.iter(): + if _strip_tag(de.tag) not in {"descripcionEspecifica", "descripcion_especifica"}: + continue + desc_especificas.append( + CoveDescEspecificaView( + marca=_find_child_text(de, ["marca"]), + modelo=_find_child_text(de, ["modelo"]), + submodelo=_find_child_text(de, ["submodelo"]), + numero_serie=_find_child_text(de, ["numeroSerie", "numero_serie", "serie"]), + ) + ) + + items.append( + CoveItemView( + descripcion_generica=desc_generica, + clave_unidad_medida=clave_uom, + cantidad=_fmt_decimal(cantidad), + tipo_moneda=tipo_moneda, + valor_unitario=_fmt_decimal(valor_unitario), + valor_total=_fmt_decimal(valor_total), + valor_dolares=_fmt_decimal(valor_dolares), + descripciones_especificas=desc_especificas, + ) + ) + + return items + + +# --------------------------------------------------------------------------- +# Servicio principal +# --------------------------------------------------------------------------- + +class CoveAcuseReportService: + """ + Genera el PDF de Acuse de COVE a partir de los datos de la factura en BD. + + Patrón idéntico al de DodaReportPdfService: + 1. build_context() → CoveAcuseContext (desde BD vía FacturaCoveDomainService) + 2. render_pdf_bytes() → bytes (Jinja2 → HTML → pdfkit/wkhtmltopdf) + """ + + def __init__(self) -> None: + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(["html"]), + ) + self._tpl = self.jinja_env.get_template("acuse_cove.html") + + # ------------------------------------------------------------------ + # wkhtmltopdf config (mismo helper que DodaReportPdfService) + # ------------------------------------------------------------------ + + def _get_wkhtmltopdf_config(self): + for path in ( + shutil.which("wkhtmltopdf"), + "/usr/local/bin/wkhtmltopdf", + "/usr/bin/wkhtmltopdf", + ): + if path: + return pdfkit.configuration(wkhtmltopdf=path) + raise RuntimeError("wkhtmltopdf binary not found.") + + # ------------------------------------------------------------------ + # Construcción del contexto desde BD + # ------------------------------------------------------------------ + + def _load_cove_xml_bytes( + self, + domain: FacturaCoveDomainService, + ctx: Any, + ) -> Optional[bytes]: + """ + Intenta obtener el XML de COVE asociado a la configuración VU: + + - Primero busca en CustomsBrokerVU.xml_files_path. + - Luego en Company.ventanilla_unica.xml_files_path. + + Si encuentra una referencia que apunta a S3 (prefijo tenants/), + usa storage_s3.get_object_bytes; si es una ruta local, la lee + directamente del filesystem. + + Devuelve None si no encuentra o no puede leer ningún XML. + """ + candidates: list[str] = [] + + vu = getattr(ctx, "vu", None) + if vu is not None: + ref = getattr(vu, "xml_files_path", None) + if isinstance(ref, str) and ref.strip(): + candidates.append(ref.strip()) + + company = domain._get_company(ctx) + company_vu = getattr(company, "ventanilla_unica", None) if company else None + if company_vu is not None: + ref = getattr(company_vu, "xml_files_path", None) + if isinstance(ref, str) and ref.strip(): + candidates.append(ref.strip()) + + for ref in candidates: + try: + if ref.startswith("tenants/"): + return storage_s3.get_object_bytes(ref) + if os.path.isfile(ref): + with open(ref, "rb") as fh: + return fh.read() + except Exception: + logger.warning("No se pudo leer XML de COVE desde %s", ref, exc_info=True) + + return None + + def _build_context_from_xml_if_possible( + self, + xml_bytes: bytes, + invoice: Any, + compliance: Any, + ) -> Optional[CoveAcuseContext]: + """ + Intenta construir un CoveAcuseContext en base al XML de COVE. + + - Valida que el e-document del XML coincida con compliance.edocument. + - Si la validación falla o el XML no tiene estructura esperada, + devuelve None. + """ + try: + root = ET.fromstring(xml_bytes) + except Exception: + logger.warning("No se pudo parsear XML de COVE para factura %s", getattr(invoice, "id", None)) + return None + + edoc_invoice = (getattr(compliance, "edocument", None) or "").strip() if compliance else "" + if not edoc_invoice: + # Sin e-document en la factura no podemos ligar de forma estricta el XML. + return None + + # Buscar el bloque XML (p. ej. ...) cuyo e-document coincide + # exactamente con el de la factura. De esta forma, aunque el archivo + # contenga varios COVEs, solo usamos el que está ligado a la factura. + cove_node: Optional[ET.Element] = None + for node in root.iter(): + edoc_xml = _find_first_text(node, ["e-document", "edocument", "edocumento"]) + if edoc_xml and edoc_xml.strip().upper() == edoc_invoice.upper(): + cove_node = node + break + + if cove_node is None: + # XML no contiene ningún COVE cuyo e-document coincida con la factura. + logger.info( + "XML de COVE no contiene bloque con e-document=%s para factura %s", + edoc_invoice, + getattr(invoice, "id", None), + ) + return None + + # Cabecera / datos generales (limitado al bloque de COVE encontrado) + tipo_operacion = _find_first_text(cove_node, ["tipoOperacion", "tipo_operacion"]) + numero_factura = _find_first_text(cove_node, ["numeroFactura", "numero_factura", "factura"]) + tipo_figura = _find_first_text(cove_node, ["tipoFigura", "tipo_figura"]) + fecha_expedicion = _find_first_text(cove_node, ["fechaExpedicion", "fecha_expedicion"]) + observaciones = _find_first_text(cove_node, ["observaciones", "observacionesCove", "observaciones_cove"]) + rfc_consulta = _find_first_text(cove_node, ["rfcConsulta", "rfc_consulta"]) + patente_aduanal = _find_first_text(cove_node, ["patenteAduanal", "patente_aduanal"]) + + # Si no logramos obtener al menos número de factura ligado a ese bloque, consideramos inválido. + if not numero_factura: + logger.info( + "Bloque XML de COVE con e-document=%s no tiene numeroFactura legible; se descarta.", + edoc_invoice, + ) + return None + + emisor_view = _build_persona_from_xml(cove_node, ["emisor", "exportador"]) + destinatario_view = _build_persona_from_xml(cove_node, ["destinatario", "importador"]) + items = _build_items_from_xml(cove_node) + + return CoveAcuseContext( + tipo_operacion=tipo_operacion or "", + numero_factura=numero_factura or "", + tipo_figura=tipo_figura or "", + fecha_expedicion=fecha_expedicion or "", + observaciones=observaciones or "", + rfc_consulta=rfc_consulta or "", + patente_aduanal=patente_aduanal or "", + edocument=edoc_invoice, + vucem_operation_num=(getattr(compliance, "vucem_operation_num", None) or "") if compliance else "", + emisor=emisor_view, + destinatario=destinatario_view, + items=items, + ) + + def build_context( + self, + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + ) -> CoveAcuseContext: + """ + Construye el CoveAcuseContext reutilizando FacturaCoveDomainService. + + Flujo: + 1. Intenta leer y mapear el XML de COVE desde S3 / filesystem + (debe existir y el e-document debe coincidir con la factura). + 2. Si no es posible, falla con ValidationException (no hay XML + de COVE disponible para la factura). + """ + domain = FacturaCoveDomainService(db) + ctx = domain._load_context(invoice_id, tenant_id, company_id) + invoice = ctx.invoice + compliance = invoice.compliance_mx + + # Intentar construir el contexto exclusivamente a partir del XML de COVE, + # aprovechando los archivos almacenados en S3 / filesystem. + try: + xml_bytes = self._load_cove_xml_bytes(domain, ctx) + except Exception: + logger.warning("Error leyendo XML de COVE para factura %s", invoice.id, exc_info=True) + xml_bytes = None + + if not xml_bytes: + raise ValidationException( + "La factura aún no cuenta con XML de COVE consultado en Ventanilla Única.", + errors=[ + { + "field": "vu.xml_files_path", + "message": "No se encontró ningún archivo XML de COVE asociado a la configuración VU.", + } + ], + ) + + xml_ctx = self._build_context_from_xml_if_possible(xml_bytes, invoice, compliance) + if not xml_ctx: + raise ValidationException( + "No se pudo interpretar el XML de COVE asociado a la factura.", + errors=[ + { + "field": "cove_xml", + "message": "El XML de COVE no coincide con el e-document de la factura o no tiene la estructura esperada.", + } + ], + ) + + return xml_ctx + + # ------------------------------------------------------------------ + # Renderizado PDF + # ------------------------------------------------------------------ + + def render_pdf_bytes(self, context: CoveAcuseContext) -> bytes: + html = self._tpl.render( + tipo_operacion=context.tipo_operacion, + numero_factura=context.numero_factura, + tipo_figura=context.tipo_figura, + fecha_expedicion=context.fecha_expedicion, + observaciones=context.observaciones, + rfc_consulta=context.rfc_consulta, + patente_aduanal=context.patente_aduanal, + edocument=context.edocument, + vucem_operation_num=context.vucem_operation_num, + emisor=context.emisor, + destinatario=context.destinatario, + items=context.items, + ) + options = { + "page-size": "Letter", + "encoding": "UTF-8", + "margin-top": "5mm", + "margin-bottom": "5mm", + "margin-left": "8mm", + "margin-right": "8mm", + "enable-local-file-access": "", + } + return pdfkit.from_string( + html, + False, + options=options, + configuration=self._get_wkhtmltopdf_config(), + ) + + def build_pdf( + self, + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + ) -> bytes: + ctx = self.build_context(db, invoice_id, tenant_id, company_id) + return self.render_pdf_bytes(ctx) diff --git a/backend/api/v1/modules/a76/factura_cove/routes.py b/backend/api/v1/modules/a76/factura_cove/routes.py index 9a311de6..10984977 100644 --- a/backend/api/v1/modules/a76/factura_cove/routes.py +++ b/backend/api/v1/modules/a76/factura_cove/routes.py @@ -1,12 +1,19 @@ from __future__ import annotations +import io +import logging from typing import Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from core import storage_s3 from core.celery_app import celery_app +from core.config import settings from core.database import get_core_db +from core.exceptions import ValidationException +from core.s3_keys import cove_acuse_pdf_key from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -19,6 +26,8 @@ from .schemas import ( from .service import FacturaCoveDomainService from .tasks import factura_cove_generate +logger = logging.getLogger(__name__) + router = APIRouter() @@ -148,3 +157,93 @@ def check_cove_eligibility( eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id_int, company_id=company_id) return eligibility + +@router.get( + "/invoices/{invoice_id}/cove/acuse", + summary="Generar o descargar el Acuse de COVE en PDF", + responses={ + 200: {"content": {"application/pdf": {}}, "description": "PDF de Acuse de COVE"}, + 422: {"description": "La factura aún no tiene XML de COVE asociado"}, + 404: {"description": "Factura no encontrada"}, + }, +) +def get_cove_acuse_pdf( + invoice_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Genera el PDF de Acuse de COVE para una factura. + + - Construye el contexto del reporte a partir del XML de COVE almacenado + en la configuración VU (agente / empresa). + - Renderiza el PDF con Jinja2 + pdfkit/wkhtmltopdf (mismo patrón que DODA). + - Cachea el PDF en S3 con clave estable; en la siguiente llamada lo reutiliza. + - Devuelve StreamingResponse con Content-Type application/pdf. + + Si aún no existe un XML de COVE asociado (consulta a VU no realizada), + el servicio devolverá un error 422. + """ + from api.v1.modules.a76.invoices.models import InvoiceHeader + from .report_service import CoveAcuseReportService + + tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + + invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id) + if not invoice: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Factura {invoice_id} no encontrada.", + ) + if invoice.company_id != company_id or invoice.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="La factura no pertenece a la compañía o tenant actuales.", + ) + + pdf_key = cove_acuse_pdf_key(tenant_id, company_id, invoice_id) + filename = f"acuse_cove_{invoice.invoice_number or invoice_id}.pdf" + + # ── Caché S3: reutilizar si el PDF ya existe ──────────────── + if settings.use_s3_object_storage and storage_s3.object_exists(pdf_key): + try: + cached_pdf = storage_s3.get_object_bytes(pdf_key) + if cached_pdf: + return StreamingResponse( + io.BytesIO(cached_pdf), + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) + except Exception: + logger.warning("No se pudo leer el PDF cacheado de S3 para factura %s; regenerando.", invoice_id) + + # ── Generar PDF ───────────────────────────────────────────── + try: + svc = CoveAcuseReportService() + pdf_bytes = svc.build_pdf(db, invoice_id, tenant_id, company_id) + except ValidationException as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=exc.message, + ) + except Exception as exc: + logger.exception("Error generando PDF de Acuse de COVE para factura %s", invoice_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error al generar el Acuse de COVE: {exc}", + ) + + # ── Guardar en S3 ─────────────────────────────────────────── + if settings.use_s3_object_storage: + try: + storage_s3.put_object_bytes(pdf_key, pdf_bytes, content_type="application/pdf") + except Exception: + logger.warning("No se pudo guardar el PDF de Acuse de COVE en S3 para factura %s.", invoice_id) + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) + diff --git a/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html b/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html new file mode 100644 index 00000000..291de5ae --- /dev/null +++ b/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html @@ -0,0 +1,452 @@ + + + + + Acuse de COVE + + + + + + + + +
Datos
+ +
Datos del comprobante
+
+
+
Tipo de operación
+
{{ tipo_operacion }}
+
+
+
No. de factura
+
{{ numero_factura }}
+
+
+
Tipo de figura
+
{{ tipo_figura }}
+
+
+
Fecha de expedición
+
{{ fecha_expedicion }}
+
+
+ + +
Observaciones
+
{{ observaciones or '' }}
+ + +
RFC con permiso de consulta
+
+
+
RFC de Consulta
+
{{ rfc_consulta }}
+
+
+
Número de autorización / Patente aduanal
+
{{ patente_aduanal }}
+
+
+ +
+ + +
Datos de la factura
+ + +
+ Datos generales del proveedor +
+
+
+
Tipo de identificador
+
{{ emisor.tipo_identificador }}
+
+
+
Tax ID / Sin Tax / RFC / CURP
+
{{ emisor.identificacion }}
+
+
+
Nombre o Razón Social
+
{{ emisor.nombre }}
+
+
+
Apellido paterno
+
{{ emisor.apellido_paterno }}
+
+
+
+
+
Apellido materno
+
{{ emisor.apellido_materno }}
+
+
+
Certificado de origen
+
+
+
+
No. exportador autorizado
+
+
+
+ +
+ Domicilio del proveedor +
+
+
+
Calle
+
{{ emisor.calle }}
+
+
+
No. exterior
+
{{ emisor.numero_exterior }}
+
+
+
No. interior
+
{{ emisor.numero_interior }}
+
+
+
Código postal
+
{{ emisor.codigo_postal }}
+
+
+
+
+
Colonia
+
{{ emisor.colonia }}
+
+
+
Localidad
+
{{ emisor.localidad }}
+
+
+
Municipio
+
{{ emisor.municipio }}
+
+
+
Entidad federativa
+
{{ emisor.entidad_federativa }}
+
+
+
+
+
País
+
{{ emisor.pais }}
+
+
+
+ +
+ + +
+ Datos generales del destinatario +
+
+
+
Tipo de identificador
+
{{ destinatario.tipo_identificador }}
+
+
+
Tax ID / Sin Tax / RFC / CURP
+
{{ destinatario.identificacion }}
+
+
+
Nombre o Razón Social
+
{{ destinatario.nombre }}
+
+
+
Apellido paterno
+
{{ destinatario.apellido_paterno }}
+
+
+
+
+
Apellido materno
+
{{ destinatario.apellido_materno }}
+
+
+
+ +
+ Domicilio del destinatario +
+
+
+
Calle
+
{{ destinatario.calle }}
+
+
+
No. exterior
+
{{ destinatario.numero_exterior }}
+
+
+
No. interior
+
{{ destinatario.numero_interior }}
+
+
+
Código postal
+
{{ destinatario.codigo_postal }}
+
+
+
+
+
Colonia
+
{{ destinatario.colonia }}
+
+
+
Localidad
+
{{ destinatario.localidad }}
+
+
+
Municipio
+
{{ destinatario.municipio }}
+
+
+
Entidad federativa
+
{{ destinatario.entidad_federativa }}
+
+
+
+
+
País
+
{{ destinatario.pais }}
+
+
+
+ +
+ + +
Datos de la mercancía
+ + {% if items %} + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + {% if item.descripciones_especificas %} + + + + {% endif %} + {% endfor %} + +
Descripción genérica de la mercancíaClave UMCCantidad UMCTipo monedaValor unitarioValor totalValor total en dólares
{{ item.descripcion_generica }}{{ item.clave_unidad_medida }}{{ item.cantidad }}{{ item.tipo_moneda }}$ {{ item.valor_unitario }}$ {{ item.valor_total }}$ {{ item.valor_dolares }}
+ + + + + + + + {% for desc in item.descripciones_especificas %} + + + + + + + {% endfor %} +
MarcaModeloSerieSubmodelo
{{ desc.marca }}{{ desc.modelo }}{{ desc.numero_serie }}{{ desc.submodelo }}
+
+ {% else %} +
+ Sin partidas registradas. +
+ {% endif %} + + + + + + diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index bf5720fa..41767d83 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -344,6 +344,19 @@ def expediente_archivo_artifact_key( return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}" +def cove_acuse_pdf_key( + tenant_id: Union[int, str], + company_id: int, + invoice_id: int, +) -> str: + """ + PDF de Acuse de COVE bajo + ``.../invoices/{invoice_id}/cove/acuse_cove.pdf`` (clave estable por factura). + """ + iid = _segment(invoice_id, "invoice_id") + return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/acuse_cove.pdf" + + def help_asset_key(folder: str, new_filename: str) -> str: """ folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index a2c8302d..73f25a95 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -584,5 +584,12 @@ export const invoicesApi = { can_generate: boolean; reasons: Array<{ field: string; message: string }>; }>(`/v1/a76/factura-cove/invoices/${invoiceId}/cove/eligibility?${params.toString()}`); + }, + + getCoveReceipt: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob( + `/v1/a76/factura-cove/invoices/${invoiceId}/cove/acuse?${params.toString()}` + ); } }; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 7fb32bb5..9fd6feae 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -883,6 +883,20 @@ const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : ''; toast.success(baseMsg + taskInfo); } else if (result.status === 'validation_error' || result.status === 'error') { + // El diálogo de progreso ya muestra `result.errors` detallados; reforzamos con toast breve. + const errs = Array.isArray(result.errors) ? result.errors : []; + if (errs.length > 0) { + const first = errs[0]; + const line = + typeof first?.message === 'string' + ? first.message + : String(first?.message ?? ''); + const more = + errs.length > 1 ? ` (+${errs.length - 1} más — revisa el cuadro de diálogo)` : ''; + toast.error(`${line}${more}`); + } else if (typeof result.message === 'string' && result.message.trim()) { + toast.error(result.message.trim()); + } reloadData(); return; } else if ( @@ -1297,6 +1311,38 @@ { label: 'Finalizando validaciones de COVE', percent: 95 } ]; + let isDownloadingCoveReceipt = $state(false); + + async function handleDownloadCoveReceipt() { + if (!selectedInvoice || !companyStore.activeCompany) { + toast.info(m.invoice_list_toasts_select_invoice_for_cove()); + return; + } + isDownloadingCoveReceipt = true; + try { + const blob = await invoicesApi.getCoveReceipt( + selectedInvoice.id, + companyStore.activeCompany.id + ); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `acuse_cove_${selectedInvoice.invoice_number || selectedInvoice.id}.pdf`; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Acuse de COVE descargado correctamente.'); + } catch (e: any) { + const msg = + e?.message || + 'Error al obtener el Acuse de COVE. Verifica que la factura tenga COVE generado.'; + toast.error(msg); + } finally { + isDownloadingCoveReceipt = false; + } + } + // Opciones de tipo de operación para el filtro const operationTypeOptions = $derived.by(() => [ { value: '', label: m.invoice_list_operation_types_all() }, @@ -1715,17 +1761,24 @@ {m.invoice_list_footer_vu_addenda()} - +