diff --git a/backend/api/v1/modules/a76/factura_cove/report_service.py b/backend/api/v1/modules/a76/factura_cove/report_service.py index 18042343..f00188df 100644 --- a/backend/api/v1/modules/a76/factura_cove/report_service.py +++ b/backend/api/v1/modules/a76/factura_cove/report_service.py @@ -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 o (o etiquetas equivalentes) en el XML de COVE. - Este mapeo es heurístico y se basa en los nombres de campos del manual COVE - y del código legacy (Loc:Emisor_*, Loc:Des_*). + 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 . """ - 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 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 ( + "" + "" + "" + f"" + "" + ) + 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, diff --git a/backend/api/v1/modules/a76/factura_cove/routes.py b/backend/api/v1/modules/a76/factura_cove/routes.py index 10984977..969feaa0 100644 --- a/backend/api/v1/modules/a76/factura_cove/routes.py +++ b/backend/api/v1/modules/a76/factura_cove/routes.py @@ -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: diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index 5e20ed3f..73d6ddb6 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -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, diff --git a/backend/api/v1/modules/a76/factura_cove/tasks.py b/backend/api/v1/modules/a76/factura_cove/tasks.py index c5e42129..7d7a65f6 100644 --- a/backend/api/v1/modules/a76/factura_cove/tasks.py +++ b/backend/api/v1/modules/a76/factura_cove/tasks.py @@ -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: , , , . + """ + 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ó true - - edocumento: el número de COVE asignado por VUCEM () si no hay error + - edocumento: el número de COVE asignado por VUCEM () 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 (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 como mensaje general o / para errores text = (node.text or "").strip() - if text: + if text and tiene_error: mensajes_error.append(text) return tiene_error, edocumento, mensajes_error diff --git a/backend/api/v1/modules/a76/factura_cove/templates/FooterCOVE.png b/backend/api/v1/modules/a76/factura_cove/templates/FooterCOVE.png new file mode 100644 index 00000000..e2d614c1 Binary files /dev/null and b/backend/api/v1/modules/a76/factura_cove/templates/FooterCOVE.png differ diff --git a/backend/api/v1/modules/a76/factura_cove/templates/HeaderCOVE.png b/backend/api/v1/modules/a76/factura_cove/templates/HeaderCOVE.png new file mode 100644 index 00000000..76db6704 Binary files /dev/null and b/backend/api/v1/modules/a76/factura_cove/templates/HeaderCOVE.png differ diff --git a/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html b/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html index 291de5ae..6c632270 100644 --- a/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html +++ b/backend/api/v1/modules/a76/factura_cove/templates/acuse_cove.html @@ -1,452 +1,518 @@ - - Acuse de COVE - + /* 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; + } + } + - - +
- -
Datos
+ +
+ Encabezado COVE +
-
Datos del comprobante
-
-
-
Tipo de operación
-
{{ tipo_operacion }}
-
-
-
No. de factura
-
{{ numero_factura }}
-
-
-
Tipo de figura
-
{{ tipo_figura }}
-
-
-
Fecha de expedición
-
{{ fecha_expedicion }}
-
-
- - -
Observaciones
-
{{ observaciones or '' }}
- - -
RFC con permiso de consulta
-
-
-
RFC de Consulta
-
{{ rfc_consulta }}
-
-
-
Número de autorización / Patente aduanal
-
{{ patente_aduanal }}
-
-
- -
- - -
Datos de la factura
- - -
- Datos generales del proveedor -
-
-
-
Tipo de identificador
-
{{ emisor.tipo_identificador }}
-
-
-
Tax ID / Sin Tax / RFC / CURP
-
{{ emisor.identificacion }}
-
-
-
Nombre o Razón Social
-
{{ emisor.nombre }}
-
-
-
Apellido paterno
-
{{ emisor.apellido_paterno }}
-
-
-
-
-
Apellido materno
-
{{ emisor.apellido_materno }}
-
-
-
Certificado de origen
-
-
-
-
No. exportador autorizado
-
-
-
- -
- Domicilio del proveedor -
-
-
-
Calle
-
{{ emisor.calle }}
-
-
-
No. exterior
-
{{ emisor.numero_exterior }}
-
-
-
No. interior
-
{{ emisor.numero_interior }}
-
-
-
Código postal
-
{{ emisor.codigo_postal }}
-
-
-
-
-
Colonia
-
{{ emisor.colonia }}
-
-
-
Localidad
-
{{ emisor.localidad }}
-
-
-
Municipio
-
{{ emisor.municipio }}
-
-
-
Entidad federativa
-
{{ emisor.entidad_federativa }}
-
-
-
-
-
País
-
{{ emisor.pais }}
-
-
-
- -
- - -
- Datos generales del destinatario -
-
-
-
Tipo de identificador
-
{{ destinatario.tipo_identificador }}
-
-
-
Tax ID / Sin Tax / RFC / CURP
-
{{ destinatario.identificacion }}
-
-
-
Nombre o Razón Social
-
{{ destinatario.nombre }}
-
-
-
Apellido paterno
-
{{ destinatario.apellido_paterno }}
-
-
-
-
-
Apellido materno
-
{{ destinatario.apellido_materno }}
-
-
-
- -
- Domicilio del destinatario -
-
-
-
Calle
-
{{ destinatario.calle }}
-
-
-
No. exterior
-
{{ destinatario.numero_exterior }}
-
-
-
No. interior
-
{{ destinatario.numero_interior }}
-
-
-
Código postal
-
{{ destinatario.codigo_postal }}
-
-
-
-
-
Colonia
-
{{ destinatario.colonia }}
-
-
-
Localidad
-
{{ destinatario.localidad }}
-
-
-
Municipio
-
{{ destinatario.municipio }}
-
-
-
Entidad federativa
-
{{ destinatario.entidad_federativa }}
-
-
-
-
-
País
-
{{ destinatario.pais }}
-
-
-
- -
- - -
Datos de la mercancía
- - {% if items %} - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - {% if item.descripciones_especificas %} - - - - {% endif %} - {% endfor %} - -
Descripción genérica de la mercancíaClave UMCCantidad UMCTipo monedaValor unitarioValor totalValor total en dólares
{{ item.descripcion_generica }}{{ item.clave_unidad_medida }}{{ item.cantidad }}{{ item.tipo_moneda }}$ {{ item.valor_unitario }}$ {{ item.valor_total }}$ {{ item.valor_dolares }}
- + +
- - - - + + {% if edocument %} + + {% endif %} + +
MarcaModeloSerieSubmodelo +
Datos del Acuse de Valor:
+
COMPROBANTE DE VALOR ELECTRÓNICO
+
+ {{ edocument }} +
+ + + + + + + + + + + + + + + + + + +
Tipo de operaciónRelación de facturasNo. de factura
{{ tipo_operacion }}{{ numero_factura_relacionada }}{{ numero_factura }}
+ + + + + + + + + + + + + + +
Tipo de figuraFecha Exp.
{{ tipo_figura }}{{ fecha_expedicion }}
+ + + + + + + + + + + +
Observaciones
{{ observaciones }}
+ +
RFC Con permiso de consulta
+ + + + + + + + + + + + + +
RFC de ConsultaNombre o Razón Social
{{ rfc_consulta }}{{ nombre_razon_social_consulta }}
+ +
Número de patente aduanal
+ + + + + + + + + + +
Número de autorización aduanal
{{ patente_aduanal }}
+ +
Datos de la factura
+ + + + + + + + + + + + + + + + +
SubdivisiónCertificado de origenNo. de exportador autorizado
{{ subdivision }}{{ certificado_origen }}{{ exportador_autorizado }}
+ + +
Datos generales del proveedor
+ + + + + + + + + + + + + +
Tipo de identificadorTax ID/Sin Tax/RFC/CURP
{{ emisor.tipo_identificador }}{{ emisor.identificacion }}
+ + + + + + + + + + + + + + + + + +
Nombre(s) o Razón SocialApellido paternoApellido materno
{{ emisor.nombre }}{{ emisor.apellido_paterno }}{{ emisor.apellido_materno }}
+ +
Domicilio del proveedor
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CalleNo. exteriorNo. interiorCódigo postal
{{ emisor.calle }}{{ emisor.numero_exterior }}{{ emisor.numero_interior }}{{ emisor.codigo_postal }}
ColoniaLocalidad
{{ emisor.colonia }}{{ emisor.localidad }}
Entidad federativaMunicipio
{{ emisor.entidad_federativa }}{{ emisor.municipio }}
País
{{ emisor.pais }}
+ + +
Datos generales del destinatario
+ + + + + + + + + + + + + +
Tipo de IdentificadorTax ID/Sin Tax/RFC/CURP
{{ destinatario.tipo_identificador }}{{ destinatario.identificacion }}
+ + + + + + + + + + + + + + + + + +
Nombre(s) o Razón SocialApellido paternoApellido materno
{{ destinatario.nombre }}{{ destinatario.apellido_paterno }}{{ destinatario.apellido_materno }}
+ +
Domicilio del destinatario
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CalleNo. exteriorNo. interiorCódigo postal
{{ destinatario.calle }}{{ destinatario.numero_exterior }}{{ destinatario.numero_interior }}{{ destinatario.codigo_postal }}
ColoniaLocalidad
{{ destinatario.colonia }}{{ destinatario.localidad }}
Entidad federativaMunicipio
{{ destinatario.entidad_federativa }}{{ destinatario.municipio }}
País
{{ destinatario.pais }}
+ + + {% if items %} + {% for item in items %} +
Datos de la mercancía
+ + + + + + + + + + + + + + + + +
Descripción genérica de la mercancíaClave UMCCantidad UMC
{{ item.descripcion_generica }}{{ item.clave_unidad_medida }}{{ item.cantidad }}
+ + + + + + + + + + + + + + + + + + + + +
Tipo monedaValor unitarioValor totalValor total en dólares
{{ item.tipo_moneda }}$ {{ item.valor_unitario }}$ {{ item.valor_total }}$ {{ item.valor_dolares }}
+ + {% if item.descripciones_especificas %} +
Descripción de la mercancía
+ + + + + + + + + + + + {% for desc in item.descripciones_especificas %} - - - - + + + + {% endfor %} -
MarcaModeloSerieSubmodelo
{{ desc.marca }}{{ desc.modelo }}{{ desc.numero_serie }}{{ desc.submodelo }}{{ desc.marca }}{{ desc.modelo }}{{ desc.numero_serie }}{{ desc.submodelo }}
-
- {% else %} -
- Sin partidas registradas. -
- {% endif %} + + {% endif %} - - + {% endfor %} + {% else %} + + + + +
Sin partidas registradas.
+ {% endif %} + + +
+ Pie de página COVE +
+ +
- + \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index 73f25a95..e4d46239 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -586,8 +586,9 @@ export const invoicesApi = { }>(`/v1/a76/factura-cove/invoices/${invoiceId}/cove/eligibility?${params.toString()}`); }, - getCoveReceipt: (invoiceId: number, companyId: number): Promise => { + getCoveReceipt: (invoiceId: number, companyId: number, forceRefresh = false): Promise => { const params = new URLSearchParams({ company_id: companyId.toString() }); + if (forceRefresh) params.set('force_refresh', 'true'); return api.getBlob( `/v1/a76/factura-cove/invoices/${invoiceId}/cove/acuse?${params.toString()}` ); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 6d58da6c..b4b11a2b 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -1332,7 +1332,8 @@ try { const blob = await invoicesApi.getCoveReceipt( selectedInvoice.id, - companyStore.activeCompany.id + companyStore.activeCompany.id, + true ); const url = URL.createObjectURL(blob); const a = document.createElement('a');