bak
This commit is contained in:
577
backend/api/v1/modules/a76/factura_cove/report_service.py
Normal file
577
backend/api/v1/modules/a76/factura_cove/report_service.py
Normal 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)
|
||||||
@@ -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}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -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()}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
Reference in New Issue
Block a user