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
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core import storage_s3
|
||||
from core.celery_app import celery_app
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.exceptions import ValidationException
|
||||
from core.s3_keys import cove_acuse_pdf_key
|
||||
from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource
|
||||
|
||||
from api.v1.modules.core.tasks_tracking import track_and_dispatch
|
||||
@@ -19,6 +26,8 @@ from .schemas import (
|
||||
from .service import FacturaCoveDomainService
|
||||
from .tasks import factura_cove_generate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -148,3 +157,93 @@ def check_cove_eligibility(
|
||||
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id_int, company_id=company_id)
|
||||
return eligibility
|
||||
|
||||
|
||||
@router.get(
|
||||
"/invoices/{invoice_id}/cove/acuse",
|
||||
summary="Generar o descargar el Acuse de COVE en PDF",
|
||||
responses={
|
||||
200: {"content": {"application/pdf": {}}, "description": "PDF de Acuse de COVE"},
|
||||
422: {"description": "La factura aún no tiene XML de COVE asociado"},
|
||||
404: {"description": "Factura no encontrada"},
|
||||
},
|
||||
)
|
||||
def get_cove_acuse_pdf(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera el PDF de Acuse de COVE para una factura.
|
||||
|
||||
- Construye el contexto del reporte a partir del XML de COVE almacenado
|
||||
en la configuración VU (agente / empresa).
|
||||
- Renderiza el PDF con Jinja2 + pdfkit/wkhtmltopdf (mismo patrón que DODA).
|
||||
- Cachea el PDF en S3 con clave estable; en la siguiente llamada lo reutiliza.
|
||||
- Devuelve StreamingResponse con Content-Type application/pdf.
|
||||
|
||||
Si aún no existe un XML de COVE asociado (consulta a VU no realizada),
|
||||
el servicio devolverá un error 422.
|
||||
"""
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from .report_service import CoveAcuseReportService
|
||||
|
||||
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
|
||||
|
||||
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Factura {invoice_id} no encontrada.",
|
||||
)
|
||||
if invoice.company_id != company_id or invoice.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="La factura no pertenece a la compañía o tenant actuales.",
|
||||
)
|
||||
|
||||
pdf_key = cove_acuse_pdf_key(tenant_id, company_id, invoice_id)
|
||||
filename = f"acuse_cove_{invoice.invoice_number or invoice_id}.pdf"
|
||||
|
||||
# ── Caché S3: reutilizar si el PDF ya existe ────────────────
|
||||
if settings.use_s3_object_storage and storage_s3.object_exists(pdf_key):
|
||||
try:
|
||||
cached_pdf = storage_s3.get_object_bytes(pdf_key)
|
||||
if cached_pdf:
|
||||
return StreamingResponse(
|
||||
io.BytesIO(cached_pdf),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("No se pudo leer el PDF cacheado de S3 para factura %s; regenerando.", invoice_id)
|
||||
|
||||
# ── Generar PDF ─────────────────────────────────────────────
|
||||
try:
|
||||
svc = CoveAcuseReportService()
|
||||
pdf_bytes = svc.build_pdf(db, invoice_id, tenant_id, company_id)
|
||||
except ValidationException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=exc.message,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Error generando PDF de Acuse de COVE para factura %s", invoice_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error al generar el Acuse de COVE: {exc}",
|
||||
)
|
||||
|
||||
# ── Guardar en S3 ───────────────────────────────────────────
|
||||
if settings.use_s3_object_storage:
|
||||
try:
|
||||
storage_s3.put_object_bytes(pdf_key, pdf_bytes, content_type="application/pdf")
|
||||
except Exception:
|
||||
logger.warning("No se pudo guardar el PDF de Acuse de COVE en S3 para factura %s.", invoice_id)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user