Files
descarga_factura_transporte/observaciones.py
T
GeorgeandClaude Sonnet 5.5 1c57988f54 se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea
- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo,
  serie, número) en facturas de compra no anuladas y en drafts; si existe no
  se crea otro draft y se responde "ya está registrada" sin pisar el registro
  de una aceptación anterior.
- SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout,
  sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída
  de Service Layer con HANA sin resultados ya no se lee como "guía no existe".
- Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla
  "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin
  repetir avisos; al agotarse se avisa al equipo.
- La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un
  correo no se repite al reintentarlo.
- seguimiento: columna intentos (con migración), registrar_intento y el
  estado de notificación en_espera.
- database_utilities.DB_NAME pasa a ser absoluto (junto al código).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00

166 lines
7.0 KiB
Python

"""
Catálogo de observaciones: los motivos por los que se rechaza un comprobante.
Cada observación se muestra al proveedor en la plantilla de rechazo con un
título, unas filas de detalle y un texto de "cómo corregir". **Los textos de
este archivo son un borrador** para revisar y ajustar; no hace falta tocar la
lógica para cambiarlos.
``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en
observaciones; el resto son los motivos de un correo sin comprobante válido.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Observacion:
codigo: str # identificador estable (se guarda en la BD)
titulo: str
filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle
como_corregir: str = ""
def a_dict(self) -> dict:
"""Forma serializable: se guarda en ``comprobantes.observaciones``."""
return {
"codigo": self.codigo,
"titulo": self.titulo,
"filas": [{"etiqueta": e, "valor": v} for e, v in self.filas],
"como_corregir": self.como_corregir,
}
# ============================================================================
# Formato
# ============================================================================
def moneda(valor: float | int | None) -> str:
"""``1250`` → ``'S/ 1,250.00'``."""
return f"S/ {float(valor or 0):,.2f}"
def texto_observaciones(cantidad: int) -> str:
"""``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``."""
return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones"
def _nombre_guia(guia: dict) -> str:
return f"{guia.get('serie', '')}-{guia.get('numero', '')}"
# ============================================================================
# Motivos de rechazo de una factura (validación contra SAP)
# ============================================================================
def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float,
guias: list[str], varias_lineas: bool) -> Observacion:
filas = []
if varias_lineas:
filas.append(("Línea", str(numero_linea)))
filas += [
("Guías", ", ".join(guias)),
("Importe en su factura", moneda(importe_factura)),
("Importe esperado", moneda(importe_esperado)),
]
return Observacion(
"precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas),
"Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.",
)
def guia_no_encontrada(guia: str) -> Observacion:
return Observacion(
"guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),),
"Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.",
)
def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
filas = [("Línea", str(numero_linea))]
if descripcion:
filas.append(("Descripción", descripcion[:60]))
return Observacion(
"linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas),
"Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.",
)
def factura_ya_registrada(numero_comprobante: str) -> Observacion:
return Observacion(
"factura_ya_registrada", "Esta factura ya está registrada en nuestro sistema",
(("Comprobante", numero_comprobante),),
"No necesita enviarla otra vez. Si tiene dudas sobre su estado, escríbanos indicando el código de seguimiento.",
)
def cantidad_cero(numero_linea: int) -> Observacion:
return Observacion(
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
"Corrija la cantidad de la línea y emita el comprobante nuevamente.",
)
# ============================================================================
# Motivos de rechazo de un correo sin comprobante válido
# ============================================================================
def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
return Observacion(
"sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
(("Archivos recibidos", str(archivos_recibidos)),),
"Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
)
def falta_xml(pdfs_recibidos: int) -> Observacion:
return Observacion(
"falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
"Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
)
def sin_factura(documentos_recibidos: list[str]) -> Observacion:
return Observacion(
"sin_factura", "No encontramos una factura electrónica en su correo",
(("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
"Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
)
# ============================================================================
# Resultado de la validación de precios → observaciones
# ============================================================================
def _sin_datos_sap(item: dict) -> bool:
"""La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
"""
Observaciones de una factura a partir de los resultados de ``validate_prices``
(uno por línea). Una línea que no coincide da una observación por cada guía
que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
"""
lineas = factura.get("lineas", [])
varias_lineas = len(lineas) > 1
observaciones: list[Observacion] = []
for i, resultado in enumerate(resultados_val):
if resultado.get("ok"):
continue
numero = i + 1
linea = lineas[i] if i < len(lineas) else {}
etiqueta = resultado.get("label", "")
if etiqueta == "Cantidad cero, sin validar":
observaciones.append(cantidad_cero(numero))
elif etiqueta == "Sin guía de referencia":
observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
else:
guias_lt = resultado.get("guias_lt", [])
no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
if len(no_encontradas) < len(guias_lt):
observaciones.append(precio_no_coincide(
numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
[_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
))
return observaciones