- 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>
301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""
|
|
Respuesta al proveedor: arma y envía el correo de aceptación o rechazo (plantillas
|
|
``plantilla/plantilla-aceptado.html`` y ``plantilla-rechazado.html``) a quien envió
|
|
el correo con el XML y el PDF.
|
|
|
|
Dos modos, según ``NOTIFICACION_MODO`` en el .env:
|
|
|
|
prueba (por defecto) la respuesta NO sale al proveedor: va al equipo
|
|
(``NOTIFICACION_EQUIPO``) con el asunto "[PRUEBA → remitente]".
|
|
real la respuesta va al remitente, con copia oculta al equipo.
|
|
|
|
Como el sistema contesta solo y a terceros, **no responde** a: remitentes
|
|
desconocidos o sin dominio válido, el propio buzón, direcciones ``no-reply`` y
|
|
afines, mensajes automáticos (respuestas automáticas, rebotes) y, en correos sin
|
|
comprobante, a respuestas (``Re:``) o mensajes que citan un código nuestro. Además
|
|
limita a ``MAX_RESPUESTAS_CORREO`` por remitente cada 24 h las respuestas a correos
|
|
sin comprobante.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from datetime import datetime
|
|
|
|
from config_validator import MODOS_NOTIFICACION as MODOS
|
|
import observaciones as obs
|
|
import outlook_utilities
|
|
import plantillas
|
|
import seguimiento
|
|
from pipeline_utils import log
|
|
|
|
EQUIPO_POR_DEFECTO = ["jlopez@aberio.com.pe", "gcalle@pucp.pe"]
|
|
MAX_RESPUESTAS_CORREO = 3
|
|
|
|
_DIRECCION = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
_AUTOMATICA = re.compile(r"no[-_.]?reply|do[-_.]?not[-_.]?reply|no[-_.]?responder|mailer-daemon|postmaster|bounce", re.I)
|
|
_CODIGO = re.compile(r"ABR-\d{6}-\d{5}")
|
|
_RESPUESTA = re.compile(r"^\s*(re|rv)\s*:", re.I)
|
|
|
|
|
|
# ============================================================================
|
|
# Configuración (.env)
|
|
# ============================================================================
|
|
def modo() -> str:
|
|
"""``prueba`` o ``real``. Cualquier otro valor (o ninguno) es ``prueba``."""
|
|
valor = os.getenv("NOTIFICACION_MODO", "prueba").strip().lower()
|
|
return valor if valor in MODOS else "prueba"
|
|
|
|
|
|
def equipo() -> list[str]:
|
|
"""Direcciones internas (``NOTIFICACION_EQUIPO``, separadas por coma o punto y coma)."""
|
|
lista = [d.strip() for d in re.split(r"[;,]", os.getenv("NOTIFICACION_EQUIPO", "")) if d.strip()]
|
|
return lista or list(EQUIPO_POR_DEFECTO)
|
|
|
|
|
|
def contacto_correo() -> str:
|
|
return os.getenv("NOTIFICACION_CONTACTO", "").strip() or os.getenv("EMAIL", "").strip()
|
|
|
|
|
|
def envio_correo() -> str:
|
|
"""Buzón al que el proveedor debe reenviar el comprobante corregido."""
|
|
return os.getenv("EMAIL", "").strip()
|
|
|
|
|
|
def logo_url() -> str:
|
|
return os.getenv("LOGO_URL", "").strip()
|
|
|
|
|
|
# ============================================================================
|
|
# Protecciones
|
|
# ============================================================================
|
|
def motivo_omision(email: dict, buzon: str, sin_comprobante: bool = False) -> str | None:
|
|
"""
|
|
Motivo por el que NO se debe responder a este correo, o None si se puede.
|
|
|
|
Args:
|
|
email: Dict del correo (``sender``, ``subject``, ``es_automatico``).
|
|
buzon: Dirección del buzón del sistema (``EMAIL``).
|
|
sin_comprobante: True si la respuesta es un rechazo de un correo sin comprobante válido.
|
|
"""
|
|
remitente = (email.get("sender") or "").strip()
|
|
if not remitente or remitente == "Desconocido":
|
|
return "remitente desconocido"
|
|
if not _DIRECCION.match(remitente):
|
|
return f"dirección no válida ({remitente})"
|
|
if remitente.lower() == buzon.strip().lower():
|
|
return "el remitente es el propio buzón"
|
|
if _AUTOMATICA.search(remitente.split("@")[0]):
|
|
return "dirección automática (no-reply)"
|
|
if email.get("es_automatico"):
|
|
return "mensaje automático (respuesta automática o rebote)"
|
|
if sin_comprobante:
|
|
asunto = email.get("subject") or ""
|
|
if _RESPUESTA.match(asunto) or _CODIGO.search(asunto):
|
|
return "parece una respuesta a una notificación nuestra"
|
|
return None
|
|
|
|
|
|
def _destinatarios(remitente: str) -> tuple[list[str], list[str], str]:
|
|
"""(para, copia oculta, prefijo del asunto) según el modo."""
|
|
if modo() == "real":
|
|
return [remitente], equipo(), ""
|
|
return equipo(), [], f"[PRUEBA → {remitente}] "
|
|
|
|
|
|
# ============================================================================
|
|
# Contexto de las plantillas
|
|
# ============================================================================
|
|
def _fecha_hora(email: dict) -> tuple[str, str]:
|
|
try:
|
|
dt = datetime.strptime(email["fecha"], "%Y-%m-%d %H:%M:%S")
|
|
except (KeyError, ValueError, TypeError):
|
|
dt = datetime.now()
|
|
return f"{dt:%d/%m/%Y}", f"{dt:%H:%M}"
|
|
|
|
|
|
def _comun(email: dict, registro: dict) -> dict:
|
|
fecha, hora = _fecha_hora(email)
|
|
return {
|
|
"logo_url": logo_url(),
|
|
"recepcion_fecha": fecha,
|
|
"recepcion_hora": hora,
|
|
"codigo_seguimiento": registro["codigo_seguimiento"],
|
|
"contacto_correo": contacto_correo(),
|
|
}
|
|
|
|
|
|
def _comprobante(factura: dict) -> dict:
|
|
numero = f"{factura.get('serie', '')}-{factura.get('numero', '')}"
|
|
tipo = "Factura electrónica" if factura.get("tipo") == "01" else factura.get("tipo_descripcion", "Comprobante")
|
|
return {
|
|
"comprobante_tipo_corto": "factura",
|
|
"comprobante_tipo": tipo,
|
|
"comprobante_numero": numero,
|
|
"proveedor_razon_social": factura.get("razon_social_emisor") or "—",
|
|
"proveedor_ruc": factura.get("ruc_emisor") or None,
|
|
}
|
|
|
|
|
|
def guias_para_plantilla(factura: dict, resultados_val: list[dict]) -> list[dict]:
|
|
"""
|
|
Filas de la tabla "Guías revisadas". La validación es por línea, así que todas las
|
|
guías de una línea llevan el mismo resultado; el importe de la línea se muestra una
|
|
vez (en su primera guía) y las demás dicen que están incluidas en la línea.
|
|
"""
|
|
filas = []
|
|
for i, linea in enumerate(factura.get("lineas", [])):
|
|
resultado = resultados_val[i] if i < len(resultados_val) else {}
|
|
guias = [it["guia"] for it in resultado.get("guias_lt", [])] or linea.get("guias_remision") or []
|
|
for j, guia in enumerate(guias):
|
|
filas.append({
|
|
"guia": f"{guia.get('serie', '')}-{guia.get('numero', '')}",
|
|
"importe_factura": obs.moneda(linea.get("total_linea")) if j == 0 else "incluida en la línea",
|
|
"conforme": bool(resultado.get("ok")),
|
|
})
|
|
return filas
|
|
|
|
|
|
def _fecha_estimada_pago(draft: dict) -> str | None:
|
|
"""Fecha de vencimiento del draft, solo si es posterior a la de contabilización (hay condición de pago)."""
|
|
try:
|
|
vencimiento = datetime.strptime(draft["DocDueDate"], "%Y-%m-%d")
|
|
contabilizacion = datetime.strptime(draft["DocDate"], "%Y-%m-%d")
|
|
except (KeyError, ValueError, TypeError):
|
|
return None
|
|
return f"{vencimiento:%d/%m/%Y}" if vencimiento > contabilizacion else None
|
|
|
|
|
|
def contexto_aceptado(email: dict, registro: dict, factura: dict, resultados_val: list[dict], draft: dict) -> dict:
|
|
comprobante = _comprobante(factura)
|
|
numero = comprobante["comprobante_numero"]
|
|
return {
|
|
**_comun(email, registro), **comprobante,
|
|
"asunto": f"Aceptado · Factura {numero} · {registro['codigo_seguimiento']}",
|
|
"preheader": f"Su factura {numero} pasó todas las validaciones. No necesita hacer nada más.",
|
|
"fecha_estimada_pago": _fecha_estimada_pago(draft),
|
|
"guias": guias_para_plantilla(factura, resultados_val),
|
|
}
|
|
|
|
|
|
def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict:
|
|
texto = obs.texto_observaciones(len(observaciones))
|
|
return {
|
|
"observaciones_texto": texto,
|
|
"preheader": f"Tiene {texto}. Corríjalas y envíenos el comprobante nuevamente.",
|
|
"observaciones": [
|
|
{"numero": n, "titulo": o.titulo, "como_corregir": o.como_corregir,
|
|
"filas": [{"etiqueta": e, "valor": v} for e, v in o.filas]}
|
|
for n, o in enumerate(observaciones, start=1)
|
|
],
|
|
"envio_correo": envio_correo(),
|
|
}
|
|
|
|
|
|
def contexto_fuera_de_linea(email: dict, registro: dict, factura: dict) -> dict:
|
|
"""Aviso de que SAP no está disponible: el comprobante quedó en cola y se procesará solo."""
|
|
comprobante = _comprobante(factura)
|
|
numero = comprobante["comprobante_numero"]
|
|
return {
|
|
**_comun(email, registro), **comprobante,
|
|
"asunto": f"En cola · Factura {numero} · {registro['codigo_seguimiento']}",
|
|
"preheader": f"Su factura {numero} quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
|
|
}
|
|
|
|
|
|
def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
|
|
observaciones: list[obs.Observacion]) -> dict:
|
|
comprobante = _comprobante(factura)
|
|
guias = guias_para_plantilla(factura, resultados_val)
|
|
return {
|
|
**_comun(email, registro), **comprobante, **_bloque_observaciones(observaciones),
|
|
"asunto": f"Rechazado · Factura {comprobante['comprobante_numero']} · {registro['codigo_seguimiento']}",
|
|
"guias": guias, "tiene_guias": bool(guias),
|
|
}
|
|
|
|
|
|
def contexto_rechazo_correo(email: dict, registro: dict, observaciones: list[obs.Observacion]) -> dict:
|
|
"""Rechazo de un correo sin comprobante identificable: no hay proveedor, número ni guías que mostrar."""
|
|
return {
|
|
**_comun(email, registro), **_bloque_observaciones(observaciones),
|
|
"asunto": f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}",
|
|
"comprobante_tipo_corto": "comprobante", "comprobante_tipo": "", "comprobante_numero": None,
|
|
"proveedor_razon_social": email.get("sender") or "—", "proveedor_ruc": None,
|
|
"guias": [], "tiene_guias": False,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Envío
|
|
# ============================================================================
|
|
def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: str | None = None) -> None:
|
|
try:
|
|
seguimiento.registrar_notificacion(codigo, estado, error=error, destinatario=destinatario)
|
|
except Exception as ex:
|
|
log(f"⚠️ No se pudo registrar la notificación en seguimiento: {ex}", severity=logging.WARNING)
|
|
|
|
|
|
def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict,
|
|
sin_comprobante: bool = False, estado_exito: str = "enviada") -> str:
|
|
"""
|
|
Responde al remitente con la plantilla ``plantilla`` ("aceptado", "rechazado" o "fuera-de-linea").
|
|
|
|
Args:
|
|
estado_exito: Estado que se anota si el envío sale bien (``"en_espera"`` para el aviso
|
|
de servicio fuera de línea).
|
|
|
|
Returns:
|
|
El estado anotado en ``comprobantes``: ``estado_exito``, ``"fallida"`` u ``"omitida"``.
|
|
"""
|
|
codigo = registro["codigo_seguimiento"]
|
|
remitente = email.get("sender") or ""
|
|
|
|
motivo = motivo_omision(email, buzon, sin_comprobante)
|
|
if motivo is None and sin_comprobante and seguimiento.contar_respuestas_correo(remitente) >= MAX_RESPUESTAS_CORREO:
|
|
motivo = f"ya se respondió {MAX_RESPUESTAS_CORREO} veces a este remitente en 24 h"
|
|
if motivo:
|
|
log(f"⏭️ No se responde al proveedor: {motivo}", severity=logging.WARNING)
|
|
_anotar(codigo, "omitida", motivo)
|
|
return "omitida"
|
|
|
|
para, copia_oculta, prefijo = _destinatarios(remitente)
|
|
try:
|
|
html = plantillas.renderizar_plantilla(plantilla, contexto)
|
|
except plantillas.PlantillaError as ex:
|
|
log(f"❌ No se pudo armar la respuesta ({codigo}): {ex}", severity=logging.ERROR)
|
|
_anotar(codigo, "fallida", f"Plantilla: {ex}")
|
|
return "fallida"
|
|
|
|
enviado = outlook_utilities.send_email(
|
|
token, buzon, para, prefijo + contexto["asunto"], html, None, bcc_recipients=copia_oculta or None,
|
|
)
|
|
estado = estado_exito if enviado else "fallida"
|
|
log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
|
|
severity=logging.INFO if enviado else logging.ERROR)
|
|
_anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
|
|
return estado
|
|
|
|
|
|
def omitir_respuesta(registro: dict, motivo: str) -> None:
|
|
"""Deja anotado que a este comprobante no se le respondió, y por qué."""
|
|
_anotar(registro["codigo_seguimiento"], "omitida", motivo)
|
|
|
|
|
|
def encabezado_tecnico(email: dict, registro: dict | None) -> str:
|
|
"""Datos de contexto que se anteponen al correo técnico que recibe el equipo."""
|
|
codigo = registro["codigo_seguimiento"] if registro else "(sin código: falló el seguimiento)"
|
|
return (
|
|
f"<p><b>Remitente:</b> {email.get('sender', '')}<br>"
|
|
f"<b>Asunto:</b> {email.get('subject', '')}<br>"
|
|
f"<b>Código de seguimiento:</b> {codigo}<br>"
|
|
f"<b>Modo de notificación:</b> {modo()}</p>"
|
|
)
|
|
|
|
|
|
def enviar_a_equipo(token: str, buzon: str, asunto: str, html: str, adjuntos: list[str] | None = None) -> bool:
|
|
"""Envía el correo técnico (tablas de SAP, JSON adjuntos…) solo al equipo interno."""
|
|
destinos = equipo()
|
|
if not destinos:
|
|
return False
|
|
return outlook_utilities.send_email(token, buzon, destinos, asunto, html, adjuntos)
|