feature/reporte-bak

This commit is contained in:
2026-04-30 16:26:14 -06:00
parent b5c94ca0cc
commit 143bfded29
9 changed files with 851 additions and 493 deletions

View File

@@ -22,9 +22,11 @@ Secciones del reporte (derivadas del Report REPORT legacy en Clarion):
from __future__ import annotations
import base64
import logging
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
@@ -101,25 +103,35 @@ 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)
Loc:tipoOperacion → tipo_operacion
Loc:numeroFacturaOriginal → numero_factura_relacionada
Loc:numeroFactura numero_factura
Loc:tipoFigura → tipo_figura
Loc:fechaExpedicion → fecha_expedicion
Loc:ObservacionesCove → observaciones
Loc:rfcConsulta → rfc_consulta
Loc:NombreRazonSocial_Consulta → nombre_razon_social_consulta
Loc:patenteAduanal → patente_aduanal
Loc:subdivision → subdivision
Loc:certificadoOrigen → certificado_origen
Loc:exportadorAutorizado → exportador_autorizado
Emisor.* → emisor (CovePersonaView)
Destinatario.* → destinatario (CovePersonaView)
DetallePartidasFactura.* → items (lista de CoveItemView)
"""
# --- Sección "Datos" (cabecera) ---
tipo_operacion: str = ""
numero_factura_relacionada: str = ""
numero_factura: str = ""
tipo_figura: str = ""
fecha_expedicion: str = ""
observaciones: str = ""
rfc_consulta: str = ""
nombre_razon_social_consulta: str = ""
patente_aduanal: str = ""
subdivision: str = ""
certificado_origen: str = ""
exportador_autorizado: str = ""
edocument: str = ""
vucem_operation_num: str = ""
# --- Personas ---
@@ -217,35 +229,53 @@ def _build_persona_from_xml(root: ET.Element, role_candidates: list[str]) -> Cov
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_*).
Estrategia de búsqueda:
- Identidad/nombre: _find_child_text (hijos directos) para evitar que campos
de otros bloques hermanos contaminen el resultado.
- Domicilio: _find_first_text dentro del elemento de persona, porque el XML
de VUCEM puede anidar las direcciones en un sub-bloque <domicilio>.
"""
role_elems: list[ET.Element] = []
wanted_roles = {r.lower() for r in role_candidates}
persona_elem: Optional[ET.Element] = None
for node in root.iter():
if _strip_tag(node.tag) in wanted_roles:
role_elems.append(node)
persona_elem = node
break
if not role_elems:
if persona_elem is None:
return CovePersonaView()
e = role_elems[0]
e = persona_elem
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"]),
# Campos de identificación — busca solo hijos directos para no mezclar
# datos entre emisor y destinatario cuando ambos están en el mismo árbol.
tipo_identificador=_find_child_text(e, [
"tipoIdentificador", "tipo_identificador",
]),
identificacion=_find_child_text(e, [
# El campo del schema Pydantic se serializa como "identificacion"
"identificacion",
# Variantes legacy / VUCEM camelCase
"identificador", "taxId", "tax_id", "rfc", "sinTaxId", "sin_tax_id",
]),
nombre=_find_child_text(e, [
"nombre", "razonSocial", "razon_social", "name",
]),
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"]),
# Campos de domicilio — búsqueda recursiva dentro de la persona para
# manejar el wrapper <domicilio> que usa el esquema VUCEM.
calle=_find_first_text(e, ["calle", "street"]),
numero_exterior=_find_first_text(e, ["numeroExterior", "numero_exterior", "numExterior"]),
numero_interior=_find_first_text(e, ["numeroInterior", "numero_interior", "numInterior"]),
colonia=_find_first_text(e, ["colonia", "neighborhood"]),
codigo_postal=_find_first_text(e, ["codigoPostal", "codigo_postal", "cp", "zipCode", "zip_code"]),
localidad=_find_first_text(e, ["localidad", "ciudad", "city"]),
municipio=_find_first_text(e, ["municipio", "delegacion"]),
entidad_federativa=_find_first_text(e, [
"entidadFederativa", "entidad_federativa", "estado", "state",
]),
pais=_find_first_text(e, ["pais", "country", "pais_code", "paisCode"]),
)
@@ -289,7 +319,9 @@ def _build_items_from_xml(root: ET.Element) -> list[CoveItemView]:
# 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"}:
# _strip_tag ya devuelve minúsculas, así que los candidatos deben
# estar también en minúsculas para que el `in` funcione.
if _strip_tag(de.tag) not in {"descripcionespecifica", "descripcion_especifica"}:
continue
desc_especificas.append(
CoveDescEspecificaView(
@@ -337,6 +369,35 @@ class CoveAcuseReportService:
)
self._tpl = self.jinja_env.get_template("acuse_cove.html")
# ------------------------------------------------------------------
# Helpers de imagen
# ------------------------------------------------------------------
def _image_data_uri(self, filename: str) -> str:
"""Carga una imagen del directorio de templates y la devuelve como data URI base64.
Detecta el tipo real desde magic bytes en lugar de confiar en la extensión,
porque las imágenes del template son JPEGs renombrados como .png.
"""
img_path = self.template_dir / filename
try:
with open(img_path, "rb") as fh:
raw = fh.read()
data = base64.b64encode(raw).decode("ascii")
if raw[:2] == b"\xff\xd8":
mime = "image/jpeg"
elif raw[:8] == b"\x89PNG\r\n\x1a\n":
mime = "image/png"
elif raw[:6] in (b"GIF87a", b"GIF89a"):
mime = "image/gif"
else:
ext = img_path.suffix.lstrip(".").lower()
mime = f"image/{ext}"
return f"data:{mime};base64,{data}"
except Exception:
logger.warning("No se pudo cargar imagen del template: %s", img_path)
return ""
# ------------------------------------------------------------------
# wkhtmltopdf config (mismo helper que DodaReportPdfService)
# ------------------------------------------------------------------
@@ -468,13 +529,48 @@ class CoveAcuseReportService:
return None
# Cabecera / datos generales
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"])
# Cada lista incluye: camelCase VUCEM, snake_case del schema Pydantic,
# y variantes alternativas observadas en XMLs de VUCEM.
tipo_operacion = _find_first_text(cove_node, [
"tipoOperacion", "tipo_operacion",
])
numero_factura_relacionada = _find_first_text(cove_node, [
"numeroFacturaOriginal", "numero_factura_original",
"relacionFactura", "relacionFacturas", "numero_factura_relacionada",
"facturaRelacionada",
])
numero_factura = _find_first_text(cove_node, [
"numeroFactura", "numero_factura", "factura", "invoice_number",
])
tipo_figura = _find_first_text(cove_node, [
"tipoFigura", "tipo_figura",
])
fecha_expedicion = _find_first_text(cove_node, [
"fechaExpedicion", "fecha_expedicion", "fechaEmision", "fecha_emision",
])
observaciones = _find_first_text(cove_node, [
"observaciones", "observacionesCove", "observaciones_cove",
])
rfc_consulta = _find_first_text(cove_node, [
"rfcConsulta", "rfc_consulta", "rfcDeConsulta",
])
nombre_razon_social_consulta = _find_first_text(cove_node, [
"nombreRazonSocialConsulta", "nombre_razon_social_consulta",
"nombreConsulta", "razonSocialConsulta",
])
patente_aduanal = _find_first_text(cove_node, [
"patenteAduanal", "patente_aduanal", "patente",
])
subdivision = _find_first_text(cove_node, [
"subdivision", "tieneSubdivision", "tiene_subdivision",
])
certificado_origen = _find_first_text(cove_node, [
"certificadoOrigen", "certificado_origen",
])
exportador_autorizado = _find_first_text(cove_node, [
"exportadorAutorizado", "exportador_autorizado",
"numExportadorAutorizado", "numero_exportador_autorizado",
])
if not invoice_specific and not numero_factura:
logger.info(
@@ -492,12 +588,17 @@ class CoveAcuseReportService:
return CoveAcuseContext(
tipo_operacion=tipo_operacion or "",
numero_factura_relacionada=numero_factura_relacionada or "",
numero_factura=numero_factura or "",
tipo_figura=tipo_figura or "",
fecha_expedicion=fecha_expedicion or "",
observaciones=observaciones or "",
rfc_consulta=rfc_consulta or "",
nombre_razon_social_consulta=nombre_razon_social_consulta or "",
patente_aduanal=patente_aduanal or "",
subdivision=subdivision or "",
certificado_origen=certificado_origen or "",
exportador_autorizado=exportador_autorizado or "",
edocument=edoc_final,
vucem_operation_num=(getattr(compliance, "vucem_operation_num", None) or "") if compliance else "",
emisor=emisor_view,
@@ -576,36 +677,97 @@ class CoveAcuseReportService:
# Renderizado PDF
# ------------------------------------------------------------------
# Dimensiones de las imágenes de cabecera/pie (JPEG 1024px de ancho):
# HeaderCOVE: 149px tall → ~31mm a ancho Letter (215.9mm)
# FooterCOVE: 126px tall → ~27mm a ancho Letter
_HEADER_MARGIN_MM = "34" # 31mm imagen + 3mm buffer
_FOOTER_MARGIN_MM = "29" # 27mm imagen + 2mm buffer
@staticmethod
def _make_page_frame_html(img_data_uri: str) -> str:
"""Genera el HTML mínimo para usar como header/footer de wkhtmltopdf.
Usa resets explícitos en html/body y overflow:hidden para evitar que
wkhtmltopdf añada scrollbars al viewport del header/footer, lo que
reduciría el ancho efectivo y cortaría la imagen.
"""
return (
"<!DOCTYPE html><html><head><meta charset=\"UTF-8\">"
"<style>"
"*{margin:0;padding:0;box-sizing:border-box;}"
"html,body{width:100%;overflow:hidden;}"
"body{padding:0 8mm;}"
"img{width:100%;display:block;}"
"</style>"
"</head><body>"
f"<img src=\"{img_data_uri}\">"
"</body></html>"
)
def render_pdf_bytes(self, context: CoveAcuseContext) -> bytes:
header_uri = self._image_data_uri("HeaderCOVE.png")
footer_uri = self._image_data_uri("FooterCOVE.png")
html = self._tpl.render(
header_img=header_uri,
footer_img=footer_uri,
tipo_operacion=context.tipo_operacion,
numero_factura_relacionada=context.numero_factura_relacionada,
numero_factura=context.numero_factura,
tipo_figura=context.tipo_figura,
fecha_expedicion=context.fecha_expedicion,
observaciones=context.observaciones,
rfc_consulta=context.rfc_consulta,
nombre_razon_social_consulta=context.nombre_razon_social_consulta,
patente_aduanal=context.patente_aduanal,
subdivision=context.subdivision,
certificado_origen=context.certificado_origen,
exportador_autorizado=context.exportador_autorizado,
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(),
# Escribir los HTML de cabecera y pie en archivos temporales.
# wkhtmltopdf los renderiza en el área de margen de CADA página.
header_tmp = tempfile.NamedTemporaryFile(
suffix=".html", delete=False, mode="w", encoding="utf-8"
)
header_tmp.write(self._make_page_frame_html(header_uri))
header_tmp.close()
footer_tmp = tempfile.NamedTemporaryFile(
suffix=".html", delete=False, mode="w", encoding="utf-8"
)
footer_tmp.write(self._make_page_frame_html(footer_uri))
footer_tmp.close()
try:
options = {
"page-size": "Letter",
"encoding": "UTF-8",
"margin-top": self._HEADER_MARGIN_MM,
"margin-bottom": self._FOOTER_MARGIN_MM,
"margin-left": "0",
"margin-right": "0",
"header-html": header_tmp.name,
"footer-html": footer_tmp.name,
"header-spacing": "0",
"footer-spacing": "0",
"print-media-type": "",
"enable-local-file-access": "",
}
return pdfkit.from_string(
html,
False,
options=options,
configuration=self._get_wkhtmltopdf_config(),
)
finally:
os.unlink(header_tmp.name)
os.unlink(footer_tmp.name)
def build_pdf(
self,

View File

@@ -170,6 +170,7 @@ def check_cove_eligibility(
def get_cove_acuse_pdf(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
force_refresh: bool = Query(False, description="Forzar regeneración del PDF ignorando la caché S3"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
@@ -180,6 +181,7 @@ def get_cove_acuse_pdf(
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.
- Usa ``force_refresh=true`` para saltar la caché y regenerar el PDF.
- Devuelve StreamingResponse con Content-Type application/pdf.
Si aún no existe un XML de COVE asociado (consulta a VU no realizada),
@@ -205,8 +207,8 @@ def get_cove_acuse_pdf(
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):
# ── Caché S3: reutilizar si el PDF ya existe y no se fuerza regeneración
if not force_refresh and 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:

View File

@@ -257,14 +257,14 @@ class FacturaCoveDomainService:
if errors.has_errors():
return None
# rfc_usuario_vu = RFC del importador/empresa registrada en VUCEM (PMI, la empresa
# cuyo FIEL se usa para autenticar). En el sistema legado viene de query_tax_id.
rfc_usuario_vu = (
(getattr(vu, "query_tax_id", None) or "").strip() if vu else ""
) or (
(getattr(company_vu, "query_rfc", None) or "").strip() if company_vu else ""
)
# Clave/token del webservice: usar el valor de VU si existe, o una
# clave fija de pruebas mientras se termina la configuración real.
return ConfiguracionVU(
rfc_usuario_vu=rfc_usuario_vu,
clave_webservice=clave_webservice,
@@ -273,6 +273,42 @@ class FacturaCoveDomainService:
clave_fiel=clave_fiel_value,
)
def _normalize_country_to_m3(self, raw_country: str) -> str:
"""
Normaliza el código de país al m3_key del catálogo (GPaises / public.countries).
VUCEM requiere el código m3_key de 3 caracteres. La DB puede almacenar
códigos de 2 letras (mex_key / ame_key) o el m3_key directamente.
La búsqueda se hace en orden:
1. Coincidencia exacta en m3_key (3 chars).
2. Coincidencia en mex_key o ame_key (2 chars).
3. Fallback: el valor tal cual, truncado a 3 chars.
"""
if not raw_country:
return ""
code = raw_country.strip().upper()
try:
from api.v1.modules.public.reference_data.countries.models import Country
if len(code) == 3:
country = self.db.query(Country).filter(Country.m3_key == code).first()
if country:
return country.m3_key
if len(code) == 2:
country = (
self.db.query(Country)
.filter((Country.mex_key == code) | (Country.ame_key == code))
.first()
)
if country:
return country.m3_key
# For any length that didn't match, try m3_key with a 3-char prefix
country = self.db.query(Country).filter(Country.m3_key == code[:3]).first()
if country:
return country.m3_key
except Exception:
pass
return code[:3]
def _clientprovider_to_persona(self, cp: ClientProvider) -> PersonaCove:
"""
Construye una PersonaCove a partir de un ClientProvider + su dirección.
@@ -284,9 +320,8 @@ class FacturaCoveDomainService:
tipo_identificador = "0" if tipo_nat == "E" else "1"
identificacion = (cp.rfc or "").strip().upper()
# País: normalizar a código de 3 caracteres (ISO o catálogo VU).
raw_country = (addr.country or "") if addr and getattr(addr, "country", None) else ""
country_code = raw_country.strip().upper()[:3] if raw_country else ""
country_code = self._normalize_country_to_m3(raw_country)
return PersonaCove(
tipo_identificador=tipo_identificador,
@@ -330,9 +365,8 @@ class FacturaCoveDomainService:
if addr is None and company.addresses:
addr = company.addresses[0]
# País: normalizar a código de 3 caracteres (ISO o catálogo VU).
raw_country = (addr.country or "") if addr and getattr(addr, "country", None) else ""
country_code = raw_country.strip().upper()[:3] if raw_country else ""
country_code = self._normalize_country_to_m3(raw_country)
return PersonaCove(
tipo_identificador="1", # Empresa mexicana por defecto
@@ -392,6 +426,24 @@ class FacturaCoveDomainService:
)
if provider:
emisor_persona = self._clientprovider_to_persona(provider)
if not emisor_persona.calle:
errors.add_error(
field="emisor.calle",
message="El proveedor/exportador no tiene calle configurada (requerida por VUCEM)",
solution=[
"Completa la dirección del proveedor/exportador con la calle en el catálogo de clientes y proveedores."
],
code="EMISOR_CALLE_MISSING",
)
if not emisor_persona.pais:
errors.add_error(
field="emisor.pais",
message="El proveedor/exportador no tiene país configurado (requerido por VUCEM)",
solution=[
"Configura el código de país en la dirección del proveedor/exportador usando el catálogo de países."
],
code="EMISOR_PAIS_MISSING",
)
else:
errors.add_error(
field="emisor",
@@ -460,6 +512,25 @@ class FacturaCoveDomainService:
code="DESTINATARIO_NOT_FOUND",
)
if destinatario_persona and not destinatario_persona.calle:
errors.add_error(
field="destinatario.calle",
message="El destinatario/importador no tiene calle configurada (requerida por VUCEM)",
solution=[
"Completa la dirección del destinatario con la calle en el catálogo de clientes/proveedores o en los datos de la empresa."
],
code="DESTINATARIO_CALLE_MISSING",
)
if destinatario_persona and not destinatario_persona.pais:
errors.add_error(
field="destinatario.pais",
message="El destinatario/importador no tiene país configurado (requerido por VUCEM)",
solution=[
"Configura el código de país en la dirección del destinatario usando el catálogo de países."
],
code="DESTINATARIO_PAIS_MISSING",
)
return emisor_persona, destinatario_persona
def _build_mercancias(self, ctx: InvoiceContext, errors: ErrorCollector) -> list[MercanciaCove]:
@@ -731,14 +802,46 @@ class FacturaCoveDomainService:
correo_destino = (recipient_email or (ctx.vu.vu_email if ctx.vu else None) or "").strip() or None
# rfc_consulta = RFC del agente aduanal / representante que opera en VUCEM.
# VUCEM exige que sea distinto del RFC del que registra (rfc_usuario_vu / FIEL).
# En el sistema legado viene del campo "Usuario Web Service VU" (web_service_user),
# que contiene el RFC personal del agente (p. ej. MABL620809BY7).
# Fallback: tax_id del broker → identificación del destinatario.
rfc_consulta_value = (
(ctx.vu.web_service_user or "").strip().upper() if ctx.vu and ctx.vu.web_service_user else ""
) or (
(ctx.broker.tax_id or "").strip().upper() if ctx.broker and ctx.broker.tax_id else ""
) or (
(destinatario.identificacion or "").strip().upper() if destinatario else ""
)
if (
rfc_consulta_value
and configuracion_vu
and rfc_consulta_value == (configuracion_vu.rfc_usuario_vu or "").strip().upper()
):
raise ValidationException(
"Conflicto de RFC en COVE",
errors=[
{
"field": "rfc_consulta",
"message": (
f"El RFC de consulta ({rfc_consulta_value}) es igual al RFC del usuario VU "
f"({configuracion_vu.rfc_usuario_vu}). VUCEM no permite que ambos sean iguales."
),
"solution": [
"Captura el RFC del agente aduanal o representante en el campo "
"'Usuario Web Service VU' (web_service_user) de la configuración VU. "
"Ese RFC debe ser distinto del RFC del importador que aparece en 'R.F.C de consulta' (query_tax_id)."
],
"code": "RFC_CONSULTA_EQUALS_RFC_USUARIO_VU",
}
],
)
return FacturaCoveRequest(
configuracion_vu=configuracion_vu,
# El RFC de consulta NO debe ser igual al RFC del que registra el comprobante.
# Usamos como RFC de consulta el RFC del agente aduanal (customs broker),
# y dejamos que configuracion_vu.rfc_usuario_vu represente al contribuyente.
rfc_consulta=(
(ctx.broker.tax_id or "").strip().upper() if ctx.broker and ctx.broker.tax_id else ""
),
rfc_consulta=rfc_consulta_value,
tipo_figura=tipo_figura,
numero_factura=numero_factura,
tipo_operacion=tipo_operacion,

View File

@@ -33,6 +33,27 @@ def _strip_ns(tag: str) -> str:
return tag.strip().lower()
def _extract_edocument_from_xml(xml_bytes: bytes) -> str | None:
"""
Extrae el número de e-document (COVE) de un XML de VUCEM.
Busca los tags más comunes que VUCEM utiliza para el identificador
del documento COVE: <eDocumento>, <e-document>, <edocument>, <cove>.
"""
try:
root = ET.fromstring(xml_bytes)
except Exception:
return None
for node in root.iter():
tag = _strip_ns(node.tag)
if tag in {"edocumento", "e-document", "edocument", "cove", "numerocove", "numero_cove"}:
value = (node.text or "").strip()
if value:
return value
return None
def _parse_consulta_respuesta(b64_str: str) -> tuple[bool, str | None, list[str]]:
"""
Parsea el XML de la respuesta de VUCEM a la consulta de estado (campo
@@ -40,7 +61,7 @@ def _parse_consulta_respuesta(b64_str: str) -> tuple[bool, str | None, list[str]
Devuelve una tupla (tiene_error, edocumento, mensajes_error) donde:
- tiene_error: True si VUCEM reportó <contieneError>true</>
- edocumento: el número de COVE asignado por VUCEM (<eDocumento>) si no hay error
- edocumento: el número de COVE asignado por VUCEM (<eDocument>) si no hay error
- mensajes_error: lista de textos de error si tiene_error es True
"""
tiene_error = False
@@ -63,14 +84,16 @@ def _parse_consulta_respuesta(b64_str: str) -> tuple[bool, str | None, list[str]
if tag == "contieneerror":
tiene_error = (node.text or "").strip().lower() == "true"
elif tag == "edocumento":
elif tag in {"edocument", "edocumento"}:
# VUCEM usa <eDocument> (sin 'o') en la respuesta de consulta
value = (node.text or "").strip()
if value:
edocumento = value
elif tag == "mensaje":
elif tag in {"mensaje", "leyenda", "descripcionerror", "mensajeerror"}:
# VUCEM puede usar <leyenda> como mensaje general o <mensaje>/<descripcionError> para errores
text = (node.text or "").strip()
if text:
if text and tiene_error:
mensajes_error.append(text)
return tiene_error, edocumento, mensajes_error

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,452 +1,518 @@
<!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;
}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reporte COVE</title>
<style>
/* Resets básicos para simular una hoja impresa */
body {
background-color: #525659; /* Fondo oscuro típico de visores PDF */
margin: 0;
padding: 20px 0;
}
/* ── 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;
}
/* Contenedor principal que simula la hoja tamaño Carta (8.5 x 11 pulgadas) */
.hoja-carta {
width: 8.5in;
min-height: 11in;
margin: 0 auto;
background-color: white;
padding: 0.3in 0.4in; /* Márgenes de impresión estándar */
box-sizing: border-box;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.page-footer {
background: #003366;
color: #fff;
padding: 4px 8px;
margin-top: 6px;
font-size: 7pt;
text-align: center;
}
/* Tipografías y tamaños legacy exactos de Clarion */
.titulo-seccion {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
color: black;
margin-top: 10px;
margin-bottom: 2px;
}
/* ── 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;
}
.titulo-principal {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
color: black;
margin-bottom: 2px;
}
/* ── 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; }
/* Tablas rígidas para simular comandos BOX y LINE */
.cove-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2px; /* Pequeño gap entre cajas de Clarion */
table-layout: fixed; /* Fuerza a respetar estrictamente los anchos del colgroup */
}
.field-group {
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
padding: 2px 4px;
}
.field-group:last-child { border-right: none; }
.cove-table th, .cove-table td {
border: 1px solid black;
text-align: left;
vertical-align: top; /* Alineación arriba a la izquierda como en Clarion */
padding-top: 1px;
padding-left: 3px;
padding-right: 3px;
padding-bottom: 1px;
box-sizing: border-box;
overflow: hidden;
}
.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;
}
.cove-table th {
/* FONT('Arial',10,,FONT:bold) y FILL(COLOR:Silver) */
background-color: #C0C0C0 !important;
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
font-weight: bold;
color: black;
height: 18px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ── 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;
}
.cove-table td {
/* FONT('Times New Roman',11) */
font-family: "Times New Roman", Times, serif;
font-size: 11pt;
color: black;
height: 20px; /* Altura mínima de la banda de datos */
}
/* ── Observaciones ────────────────────────────────────────── */
.obs-box {
border: 1px solid #999;
min-height: 28px;
padding: 3px 5px;
font-size: 8pt;
white-space: pre-wrap;
}
/* Casos especiales de altura */
.td-observaciones {
min-height: 28pt;
height: 28pt;
}
/* ── 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;
}
/* Imagen fluida dentro de sus contenedores */
.img-header-footer {
width: 100%;
display: block;
}
/* ── COVE badge ───────────────────────────────────────────── */
.cove-badge {
display: inline-block;
background: #003366;
color: #fff;
font-size: 8pt;
font-weight: bold;
padding: 2px 8px;
border-radius: 2px;
}
.subtitulo {
font-family: Arial, Helvetica, sans-serif;
font-size: 8pt;
font-weight: normal;
color: #555;
margin-top: 0;
margin-bottom: 3px;
letter-spacing: 0.3px;
}
/* ── Separador ────────────────────────────────────────────── */
hr.sep { border: none; border-top: 1px solid #ccc; margin: 4px 0; }
/* Badge con el número de COVE (ej. COVE24782HO65) */
.cove-badge {
font-family: Arial, Helvetica, sans-serif;
font-size: 11pt;
font-weight: bold;
color: #ffffff;
background-color: #1a3d6e;
padding: 3px 10px;
display: inline-block;
letter-spacing: 0.5px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ── Datos principales fila ───────────────────────────────── */
.main-row {
display: flex;
gap: 4px;
margin-bottom: 2px;
}
.main-row .field-group { flex: 1; }
</style>
/* Configuración de impresión */
@media print {
body {
background-color: white;
padding: 0;
margin: 0;
}
.hoja-carta {
box-shadow: none;
width: 100%;
margin: 0;
/* Top ajustado: el header-html de wkhtmltopdf ya ocupa su margen superior.
Los 5mm aquí son solo el respiro entre el borde del área de contenido
y la primera tabla. Lados: 8mm para alinearse visualmente con la imagen. */
padding: 5mm 8mm 8mm;
}
.titulo-principal { margin-top: 0; margin-bottom: 1px; }
.browser-only { display: none; }
.cove-table th { background-color: #C0C0C0 !important; }
.cove-badge {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
background-color: #1a3d6e !important;
color: #ffffff !important;
}
}
</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>
<div class="hoja-carta">
<!-- ═══════════════════ SECCIÓN: DATOS GENERALES ══════════ -->
<div class="section-title">Datos</div>
<!-- HEADER IMAGE (solo visible en el navegador; en PDF lo repite wkhtmltopdf via --header-html) -->
<div class="browser-only">
<img src="{{ header_img }}" alt="Encabezado COVE" class="img-header-footer" style="margin-bottom: 10px;">
</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;">
<!-- Bloque de título del acuse + badge con número COVE -->
<table style="width:100%;border:none;border-collapse:collapse;margin-bottom:4px;">
<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>
<td style="border:none;padding:0;vertical-align:bottom;">
<div class="titulo-principal">Datos del Acuse de Valor:</div>
<div class="subtitulo">COMPROBANTE DE VALOR ELECTRÓNICO</div>
</td>
{% if edocument %}
<td style="border:none;padding:0;vertical-align:middle;text-align:right;white-space:nowrap;width:1%;">
<span class="cove-badge">{{ edocument }}</span>
</td>
{% endif %}
</tr>
</table>
<!-- ==========================================
SECCIÓN 1: DATOS DE LA FACTURA
=========================================== -->
<table class="cove-table">
<colgroup>
<col style="width: 33%;">
<col style="width: 34%;">
<col style="width: 33%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tipo de operación</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Relación de facturas</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. de factura</th>
</tr>
<tr>
<td>{{ tipo_operacion }}</td>
<td>{{ numero_factura_relacionada }}</td>
<td>{{ numero_factura }}</td>
</tr>
</table>
<table class="cove-table">
<colgroup>
<col style="width: 51%;">
<col style="width: 49%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tipo de figura</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Fecha Exp.</th>
</tr>
<tr>
<td>{{ tipo_figura }}</td>
<td>{{ fecha_expedicion }}</td>
</tr>
</table>
<table class="cove-table">
<colgroup>
<col style="width: 100%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Observaciones</th>
</tr>
<tr>
<td class="td-observaciones">{{ observaciones }}</td>
</tr>
</table>
<div class="titulo-seccion">RFC Con permiso de consulta</div>
<table class="cove-table">
<colgroup>
<col style="width: 51%;">
<col style="width: 49%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">RFC de Consulta</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Nombre o Razón Social</th>
</tr>
<tr>
<td>{{ rfc_consulta }}</td>
<td>{{ nombre_razon_social_consulta }}</td>
</tr>
</table>
<div class="titulo-seccion">Número de patente aduanal</div>
<table class="cove-table">
<colgroup>
<col style="width: 100%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Número de autorización aduanal</th>
</tr>
<tr>
<td>{{ patente_aduanal }}</td>
</tr>
</table>
<div class="titulo-seccion">Datos de la factura</div>
<table class="cove-table">
<colgroup>
<col style="width: 33%;">
<col style="width: 34%;">
<col style="width: 33%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Subdivisión</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Certificado de origen</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. de exportador autorizado</th>
</tr>
<tr>
<td>{{ subdivision }}</td>
<td>{{ certificado_origen }}</td>
<td>{{ exportador_autorizado }}</td>
</tr>
</table>
<!-- ==========================================
SECCIÓN 2: DATOS DEL PROVEEDOR
=========================================== -->
<div class="titulo-seccion">Datos generales del proveedor</div>
<table class="cove-table">
<colgroup>
<col style="width: 44%;">
<col style="width: 56%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tipo de identificador</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tax ID/Sin Tax/RFC/CURP</th>
</tr>
<tr>
<td>{{ emisor.tipo_identificador }}</td>
<td>{{ emisor.identificacion }}</td>
</tr>
</table>
<table class="cove-table">
<colgroup>
<col style="width: 44%;">
<col style="width: 28%;">
<col style="width: 28%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Nombre(s) o Razón Social</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Apellido paterno</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Apellido materno</th>
</tr>
<tr>
<td>{{ emisor.nombre }}</td>
<td>{{ emisor.apellido_paterno }}</td>
<td>{{ emisor.apellido_materno }}</td>
</tr>
</table>
<div class="titulo-seccion">Domicilio del proveedor</div>
<table class="cove-table">
<colgroup>
<col style="width: 51%;">
<col style="width: 16%;">
<col style="width: 19%;">
<col style="width: 14%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Calle</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. exterior</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. interior</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Código postal</th>
</tr>
<tr>
<td>{{ emisor.calle }}</td>
<td>{{ emisor.numero_exterior }}</td>
<td>{{ emisor.numero_interior }}</td>
<td>{{ emisor.codigo_postal }}</td>
</tr>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Colonia</th>
<th colspan="3" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Localidad</th>
</tr>
<tr>
<td>{{ emisor.colonia }}</td>
<td colspan="3">{{ emisor.localidad }}</td>
</tr>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Entidad federativa</th>
<th colspan="3" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Municipio</th>
</tr>
<tr>
<td>{{ emisor.entidad_federativa }}</td>
<td colspan="3">{{ emisor.municipio }}</td>
</tr>
<tr>
<th colspan="4" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">País</th>
</tr>
<tr>
<td colspan="4">{{ emisor.pais }}</td>
</tr>
</table>
<!-- ==========================================
SECCIÓN 3: DATOS DEL DESTINATARIO
=========================================== -->
<div class="titulo-seccion">Datos generales del destinatario</div>
<table class="cove-table">
<colgroup>
<col style="width: 44%;">
<col style="width: 56%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tipo de Identificador</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tax ID/Sin Tax/RFC/CURP</th>
</tr>
<tr>
<td>{{ destinatario.tipo_identificador }}</td>
<td>{{ destinatario.identificacion }}</td>
</tr>
</table>
<table class="cove-table">
<colgroup>
<col style="width: 44%;">
<col style="width: 28%;">
<col style="width: 28%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Nombre(s) o Razón Social</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Apellido paterno</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Apellido materno</th>
</tr>
<tr>
<td>{{ destinatario.nombre }}</td>
<td>{{ destinatario.apellido_paterno }}</td>
<td>{{ destinatario.apellido_materno }}</td>
</tr>
</table>
<div class="titulo-seccion">Domicilio del destinatario</div>
<table class="cove-table">
<colgroup>
<col style="width: 51%;">
<col style="width: 16%;">
<col style="width: 19%;">
<col style="width: 14%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Calle</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. exterior</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">No. interior</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Código postal</th>
</tr>
<tr>
<td>{{ destinatario.calle }}</td>
<td>{{ destinatario.numero_exterior }}</td>
<td>{{ destinatario.numero_interior }}</td>
<td>{{ destinatario.codigo_postal }}</td>
</tr>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Colonia</th>
<th colspan="3" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Localidad</th>
</tr>
<tr>
<td>{{ destinatario.colonia }}</td>
<td colspan="3">{{ destinatario.localidad }}</td>
</tr>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Entidad federativa</th>
<th colspan="3" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Municipio</th>
</tr>
<tr>
<td>{{ destinatario.entidad_federativa }}</td>
<td colspan="3">{{ destinatario.municipio }}</td>
</tr>
<tr>
<th colspan="4" bgcolor="#C0C0C0" style="background-color: #C0C0C0;">País</th>
</tr>
<tr>
<td colspan="4">{{ destinatario.pais }}</td>
</tr>
</table>
<!-- ==========================================
SECCIÓN 4: PARTIDAS / MERCANCÍAS
=========================================== -->
{% if items %}
{% for item in items %}
<div class="titulo-seccion" style="{% if not loop.first %}margin-top: 10px;{% endif %}">Datos de la mercancía</div>
<table class="cove-table">
<colgroup>
<col style="width: 58%;">
<col style="width: 22%;">
<col style="width: 20%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Descripción genérica de la mercancía</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Clave UMC</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Cantidad UMC</th>
</tr>
<tr>
<td>{{ item.descripcion_generica }}</td>
<td>{{ item.clave_unidad_medida }}</td>
<td>{{ item.cantidad }}</td>
</tr>
</table>
<table class="cove-table">
<colgroup>
<col style="width: 28%;">
<col style="width: 30%;">
<col style="width: 22%;">
<col style="width: 20%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Tipo moneda</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Valor unitario</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Valor total</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Valor total en dólares</th>
</tr>
<tr>
<td>{{ item.tipo_moneda }}</td>
<td>$ {{ item.valor_unitario }}</td>
<td>$ {{ item.valor_total }}</td>
<td>$ {{ item.valor_dolares }}</td>
</tr>
</table>
{% if item.descripciones_especificas %}
<div class="titulo-seccion" style="margin-top: 6px;">Descripción de la mercancía</div>
<table class="cove-table">
<colgroup>
<col style="width: 28%;">
<col style="width: 30%;">
<col style="width: 22%;">
<col style="width: 20%;">
</colgroup>
<tr>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Marca</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Modelo</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">Serie</th>
<th bgcolor="#C0C0C0" style="background-color: #C0C0C0;">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>
<td>{{ desc.marca }}</td>
<td>{{ desc.modelo }}</td>
<td>{{ desc.numero_serie }}</td>
<td>{{ 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 %}
</table>
{% 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>
{% endfor %}
{% else %}
<table class="cove-table">
<tr>
<td style="font-style: italic; color: #555;">Sin partidas registradas.</td>
</tr>
</table>
{% endif %}
<!-- FOOTER IMAGE (solo visible en el navegador; en PDF lo repite wkhtmltopdf via --footer-html) -->
<div class="browser-only">
<img src="{{ footer_img }}" alt="Pie de página COVE" class="img-header-footer" style="margin-top: 25px;">
</div>
</div>
</body>
</html>
</html>