This commit is contained in:
2026-04-30 07:42:02 -06:00
parent d4e5958742
commit e8db8f2a86
6 changed files with 1220 additions and 16 deletions

View File

@@ -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 <emisor> o <destinatario>
(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 <mercancia>
(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 <mercancias> como ítem
# (si tiene hijos <mercancia> 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. <cove>...</cove>) 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)

View File

@@ -1,12 +1,19 @@
from __future__ import annotations from __future__ import annotations
import io
import logging
from typing import Any, Dict 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 sqlalchemy.orm import Session
from core import storage_s3
from core.celery_app import celery_app from core.celery_app import celery_app
from core.config import settings
from core.database import get_core_db 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 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 from api.v1.modules.core.tasks_tracking import track_and_dispatch
@@ -19,6 +26,8 @@ from .schemas import (
from .service import FacturaCoveDomainService from .service import FacturaCoveDomainService
from .tasks import factura_cove_generate from .tasks import factura_cove_generate
logger = logging.getLogger(__name__)
router = APIRouter() 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) eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id_int, company_id=company_id)
return eligibility 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}"'},
)

View File

@@ -0,0 +1,452 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Acuse de COVE</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 8pt;
color: #000;
}
/* ── Header / Footer ────────────────────────────────────────── */
.page-header {
background: #003366;
color: #fff;
padding: 6px 8px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.page-header .title-main {
font-size: 13pt;
font-weight: bold;
letter-spacing: 0.5px;
}
.page-header .title-sub {
font-size: 8pt;
opacity: 0.85;
}
.page-header .logo-area {
text-align: right;
font-size: 7pt;
opacity: 0.8;
}
.page-footer {
background: #003366;
color: #fff;
padding: 4px 8px;
margin-top: 6px;
font-size: 7pt;
text-align: center;
}
/* ── Sección genérica ─────────────────────────────────────── */
.section-title {
font-size: 8.5pt;
font-weight: bold;
color: #003366;
margin: 6px 0 2px;
border-bottom: 1px solid #003366;
padding-bottom: 1px;
}
/* ── Grilla de campos ─────────────────────────────────────── */
.grid {
display: grid;
gap: 0;
border: 1px solid #999;
width: 100%;
}
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.grid-5 { grid-template-columns: 2fr 1fr 1fr 1fr 1fr; }
.field-group {
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
padding: 2px 4px;
}
.field-group:last-child { border-right: none; }
.field-label {
font-size: 6.5pt;
font-weight: bold;
color: #333;
text-transform: uppercase;
}
.field-value {
font-size: 8pt;
min-height: 12px;
padding-top: 1px;
}
/* ── Banda de encabezado de columna (fondo gris) ──────────── */
.band-header {
background: #c0c0c0;
font-weight: bold;
font-size: 7.5pt;
padding: 2px 4px;
border: 1px solid #999;
border-bottom: none;
}
/* ── Observaciones ────────────────────────────────────────── */
.obs-box {
border: 1px solid #999;
min-height: 28px;
padding: 3px 5px;
font-size: 8pt;
white-space: pre-wrap;
}
/* ── Tabla de partidas ────────────────────────────────────── */
table.partidas {
width: 100%;
border-collapse: collapse;
font-size: 7.5pt;
margin-top: 2px;
}
table.partidas th {
background: #c0c0c0;
border: 1px solid #888;
padding: 2px 4px;
text-align: left;
font-size: 7pt;
font-weight: bold;
}
table.partidas td {
border: 1px solid #ccc;
padding: 2px 4px;
vertical-align: top;
}
table.partidas td.num { text-align: right; }
table.partidas .sub-th {
background: #ddd;
font-size: 6.5pt;
font-style: italic;
}
/* ── COVE badge ───────────────────────────────────────────── */
.cove-badge {
display: inline-block;
background: #003366;
color: #fff;
font-size: 8pt;
font-weight: bold;
padding: 2px 8px;
border-radius: 2px;
}
/* ── Separador ────────────────────────────────────────────── */
hr.sep { border: none; border-top: 1px solid #ccc; margin: 4px 0; }
/* ── Datos principales fila ───────────────────────────────── */
.main-row {
display: flex;
gap: 4px;
margin-bottom: 2px;
}
.main-row .field-group { flex: 1; }
</style>
</head>
<body>
<!-- ═══════════════════ HEADER ════════════════════════════ -->
<div class="page-header">
<div>
<div class="title-main">COMPROBANTE DE VALOR ELECTRÓNICO (COVE)</div>
<div class="title-sub">Acuse de Recepción — Ventanilla Única</div>
</div>
<div class="logo-area">
{% if edocument %}
<span class="cove-badge">E-Document: {{ edocument }}</span><br/>
{% endif %}
{% if vucem_operation_num %}
<span>Op. VUCEM: {{ vucem_operation_num }}</span>
{% endif %}
</div>
</div>
<!-- ═══════════════════ SECCIÓN: DATOS GENERALES ══════════ -->
<div class="section-title">Datos</div>
<div class="band-header">Datos del comprobante</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Tipo de operación</div>
<div class="field-value">{{ tipo_operacion }}</div>
</div>
<div class="field-group">
<div class="field-label">No. de factura</div>
<div class="field-value">{{ numero_factura }}</div>
</div>
<div class="field-group">
<div class="field-label">Tipo de figura</div>
<div class="field-value">{{ tipo_figura }}</div>
</div>
<div class="field-group">
<div class="field-label">Fecha de expedición</div>
<div class="field-value">{{ fecha_expedicion }}</div>
</div>
</div>
<!-- ── Observaciones ── -->
<div class="band-header">Observaciones</div>
<div class="obs-box">{{ observaciones or '' }}</div>
<!-- ── RFC consulta / Patente ── -->
<div class="band-header">RFC con permiso de consulta</div>
<div class="grid grid-2">
<div class="field-group">
<div class="field-label">RFC de Consulta</div>
<div class="field-value">{{ rfc_consulta }}</div>
</div>
<div class="field-group">
<div class="field-label">Número de autorización / Patente aduanal</div>
<div class="field-value">{{ patente_aduanal }}</div>
</div>
</div>
<hr class="sep"/>
<!-- ═══════════════════ SECCIÓN: DATOS DE LA FACTURA ══════ -->
<div class="section-title">Datos de la factura</div>
<!-- ── Proveedor / Emisor ── -->
<div class="section-title" style="font-size:7.5pt; border-bottom:none; margin-top:4px;">
Datos generales del proveedor
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Tipo de identificador</div>
<div class="field-value">{{ emisor.tipo_identificador }}</div>
</div>
<div class="field-group">
<div class="field-label">Tax ID / Sin Tax / RFC / CURP</div>
<div class="field-value">{{ emisor.identificacion }}</div>
</div>
<div class="field-group">
<div class="field-label">Nombre o Razón Social</div>
<div class="field-value">{{ emisor.nombre }}</div>
</div>
<div class="field-group">
<div class="field-label">Apellido paterno</div>
<div class="field-value">{{ emisor.apellido_paterno }}</div>
</div>
</div>
<div class="grid grid-3">
<div class="field-group">
<div class="field-label">Apellido materno</div>
<div class="field-value">{{ emisor.apellido_materno }}</div>
</div>
<div class="field-group">
<div class="field-label">Certificado de origen</div>
<div class="field-value"></div>
</div>
<div class="field-group">
<div class="field-label">No. exportador autorizado</div>
<div class="field-value"></div>
</div>
</div>
<div class="section-title" style="font-size:7.5pt; border-bottom:none; margin-top:4px;">
Domicilio del proveedor
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Calle</div>
<div class="field-value">{{ emisor.calle }}</div>
</div>
<div class="field-group">
<div class="field-label">No. exterior</div>
<div class="field-value">{{ emisor.numero_exterior }}</div>
</div>
<div class="field-group">
<div class="field-label">No. interior</div>
<div class="field-value">{{ emisor.numero_interior }}</div>
</div>
<div class="field-group">
<div class="field-label">Código postal</div>
<div class="field-value">{{ emisor.codigo_postal }}</div>
</div>
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Colonia</div>
<div class="field-value">{{ emisor.colonia }}</div>
</div>
<div class="field-group">
<div class="field-label">Localidad</div>
<div class="field-value">{{ emisor.localidad }}</div>
</div>
<div class="field-group">
<div class="field-label">Municipio</div>
<div class="field-value">{{ emisor.municipio }}</div>
</div>
<div class="field-group">
<div class="field-label">Entidad federativa</div>
<div class="field-value">{{ emisor.entidad_federativa }}</div>
</div>
</div>
<div class="grid grid-2">
<div class="field-group">
<div class="field-label">País</div>
<div class="field-value">{{ emisor.pais }}</div>
</div>
<div class="field-group"></div>
</div>
<hr class="sep"/>
<!-- ── Destinatario ── -->
<div class="section-title" style="font-size:7.5pt; border-bottom:none; margin-top:4px;">
Datos generales del destinatario
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Tipo de identificador</div>
<div class="field-value">{{ destinatario.tipo_identificador }}</div>
</div>
<div class="field-group">
<div class="field-label">Tax ID / Sin Tax / RFC / CURP</div>
<div class="field-value">{{ destinatario.identificacion }}</div>
</div>
<div class="field-group">
<div class="field-label">Nombre o Razón Social</div>
<div class="field-value">{{ destinatario.nombre }}</div>
</div>
<div class="field-group">
<div class="field-label">Apellido paterno</div>
<div class="field-value">{{ destinatario.apellido_paterno }}</div>
</div>
</div>
<div class="grid grid-2">
<div class="field-group">
<div class="field-label">Apellido materno</div>
<div class="field-value">{{ destinatario.apellido_materno }}</div>
</div>
<div class="field-group"></div>
</div>
<div class="section-title" style="font-size:7.5pt; border-bottom:none; margin-top:4px;">
Domicilio del destinatario
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Calle</div>
<div class="field-value">{{ destinatario.calle }}</div>
</div>
<div class="field-group">
<div class="field-label">No. exterior</div>
<div class="field-value">{{ destinatario.numero_exterior }}</div>
</div>
<div class="field-group">
<div class="field-label">No. interior</div>
<div class="field-value">{{ destinatario.numero_interior }}</div>
</div>
<div class="field-group">
<div class="field-label">Código postal</div>
<div class="field-value">{{ destinatario.codigo_postal }}</div>
</div>
</div>
<div class="grid grid-4">
<div class="field-group">
<div class="field-label">Colonia</div>
<div class="field-value">{{ destinatario.colonia }}</div>
</div>
<div class="field-group">
<div class="field-label">Localidad</div>
<div class="field-value">{{ destinatario.localidad }}</div>
</div>
<div class="field-group">
<div class="field-label">Municipio</div>
<div class="field-value">{{ destinatario.municipio }}</div>
</div>
<div class="field-group">
<div class="field-label">Entidad federativa</div>
<div class="field-value">{{ destinatario.entidad_federativa }}</div>
</div>
</div>
<div class="grid grid-2">
<div class="field-group">
<div class="field-label">País</div>
<div class="field-value">{{ destinatario.pais }}</div>
</div>
<div class="field-group"></div>
</div>
<hr class="sep"/>
<!-- ═══════════════════ SECCIÓN: PARTIDAS ══════════════════ -->
<div class="section-title">Datos de la mercancía</div>
{% if items %}
<table class="partidas">
<thead>
<tr>
<th style="width:30%">Descripción genérica de la mercancía</th>
<th style="width:8%">Clave UMC</th>
<th style="width:8%">Cantidad UMC</th>
<th style="width:8%">Tipo moneda</th>
<th style="width:13%">Valor unitario</th>
<th style="width:13%">Valor total</th>
<th style="width:13%">Valor total en dólares</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.descripcion_generica }}</td>
<td>{{ item.clave_unidad_medida }}</td>
<td class="num">{{ item.cantidad }}</td>
<td>{{ item.tipo_moneda }}</td>
<td class="num">$ {{ item.valor_unitario }}</td>
<td class="num">$ {{ item.valor_total }}</td>
<td class="num">$ {{ item.valor_dolares }}</td>
</tr>
{% if item.descripciones_especificas %}
<tr>
<td colspan="7" style="padding: 0;">
<table style="width:100%; border-collapse:collapse; font-size:7pt;">
<tr>
<th class="sub-th" style="border:1px solid #ccc; padding:2px 4px; width:25%">Marca</th>
<th class="sub-th" style="border:1px solid #ccc; padding:2px 4px; width:25%">Modelo</th>
<th class="sub-th" style="border:1px solid #ccc; padding:2px 4px; width:25%">Serie</th>
<th class="sub-th" style="border:1px solid #ccc; padding:2px 4px; width:25%">Submodelo</th>
</tr>
{% for desc in item.descripciones_especificas %}
<tr>
<td style="border:1px solid #ccc; padding:2px 4px;">{{ desc.marca }}</td>
<td style="border:1px solid #ccc; padding:2px 4px;">{{ desc.modelo }}</td>
<td style="border:1px solid #ccc; padding:2px 4px;">{{ desc.numero_serie }}</td>
<td style="border:1px solid #ccc; padding:2px 4px;">{{ desc.submodelo }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:8px; border:1px solid #ccc; color:#666; font-style:italic;">
Sin partidas registradas.
</div>
{% endif %}
<!-- ═══════════════════ FOOTER ════════════════════════════ -->
<div class="page-footer">
Comprobante de Valor Electrónico — Ventanilla Única — VUCEM
{% if edocument %} | E-Document: {{ edocument }}{% endif %}
{% if vucem_operation_num %} | Operación: {{ vucem_operation_num }}{% endif %}
</div>
</body>
</html>

View File

@@ -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}" 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: def help_asset_key(folder: str, new_filename: str) -> str:
""" """
folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/

View File

@@ -584,5 +584,12 @@ export const invoicesApi = {
can_generate: boolean; can_generate: boolean;
reasons: Array<{ field: string; message: string }>; reasons: Array<{ field: string; message: string }>;
}>(`/v1/a76/factura-cove/invoices/${invoiceId}/cove/eligibility?${params.toString()}`); }>(`/v1/a76/factura-cove/invoices/${invoiceId}/cove/eligibility?${params.toString()}`);
},
getCoveReceipt: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(
`/v1/a76/factura-cove/invoices/${invoiceId}/cove/acuse?${params.toString()}`
);
} }
}; };

View File

@@ -883,6 +883,20 @@
const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : ''; const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : '';
toast.success(baseMsg + taskInfo); toast.success(baseMsg + taskInfo);
} else if (result.status === 'validation_error' || result.status === 'error') { } 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(); reloadData();
return; return;
} else if ( } else if (
@@ -1297,6 +1311,38 @@
{ label: 'Finalizando validaciones de COVE', percent: 95 } { 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 // Opciones de tipo de operación para el filtro
const operationTypeOptions = $derived.by(() => [ const operationTypeOptions = $derived.by(() => [
{ value: '', label: m.invoice_list_operation_types_all() }, { value: '', label: m.invoice_list_operation_types_all() },
@@ -1715,17 +1761,24 @@
<Files class="mr-2 h-4 w-4" /> <Files class="mr-2 h-4 w-4" />
<span>{m.invoice_list_footer_vu_addenda()}</span> <span>{m.invoice_list_footer_vu_addenda()}</span>
</button> </button>
<button <button
type="button" type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent" class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
onclick={() => { disabled={isDownloadingCoveReceipt}
showVuSubmenu = false; onclick={() => {
openCoveDialog(); showVuSubmenu = false;
}} handleDownloadCoveReceipt();
> }}
<Files class="mr-2 h-4 w-4" /> >
<span>{m.invoice_list_footer_vu_cove_receipt()}</span> <Files class="mr-2 h-4 w-4" />
</button> <span>
{#if isDownloadingCoveReceipt}
Descargando...
{:else}
{m.invoice_list_footer_vu_cove_receipt()}
{/if}
</span>
</button>
<button <button
type="button" type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent" class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
@@ -1903,10 +1956,13 @@
<Files class="mr-2 h-4 w-4" /> <Files class="mr-2 h-4 w-4" />
{m.invoice_list_footer_vu_addenda()} {m.invoice_list_footer_vu_addenda()}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCoveDialog()}> <DropdownMenu.Item
<Files class="mr-2 h-4 w-4" /> disabled={isDownloadingCoveReceipt}
{m.invoice_list_footer_vu_cove_receipt()} onclick={() => handleDownloadCoveReceipt()}
</DropdownMenu.Item> >
<Files class="mr-2 h-4 w-4" />
{isDownloadingCoveReceipt ? 'Descargando...' : m.invoice_list_footer_vu_cove_receipt()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info(m.invoice_list_submenu_massive_cove_soon())}> <DropdownMenu.Item onclick={() => toast.info(m.invoice_list_submenu_massive_cove_soon())}>
<Files class="mr-2 h-4 w-4" /> <Files class="mr-2 h-4 w-4" />
{m.invoice_list_footer_vu_massive_cove()} {m.invoice_list_footer_vu_massive_cove()}