se responde al remitente con las plantillas de aceptado/rechazado

El sistema contesta a quien envió el correo con la plantilla que corresponda.
Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el
asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta
al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo.

- respuesta_proveedor.py: contextos de las plantillas, envío y protecciones
  (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes
  automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h).
- observaciones.py: motivos de rechazo con título, filas y cómo corregir
  (borrador de textos); también para correos sin comprobante válido.
- Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y
  ya no tumba el ciclo; "guía no encontrada" exige que HANA responda.
- outlook_utilities: es_mensaje_automatico y copia oculta en send_email.
- seguimiento: notificacion_destinatario (con migración) y
  contar_respuestas_correo.
- config_validator anuncia el modo activo; documentación y pruebas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent af9e3961e5
commit 4deb9d4e4d
22 files changed
+1660 -146

No files matched your search

+104 -32
View File
@@ -20,6 +20,8 @@ import documentos as docs
import persistencia
import draft_sap
import notificaciones
import observaciones
import respuesta_proveedor
import sapb1_manager
import seguimiento
from pipeline_utils import log
@@ -61,6 +63,33 @@ def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None:
log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING)
def _registrar_correo(email: dict) -> dict | None:
"""Como ``_registrar_comprobante``, para un correo sin comprobante identificable."""
try:
registro = seguimiento.registrar_correo(email["id"])
except Exception as ex:
log(f"⚠️ No se pudo registrar el correo en seguimiento: {ex}", severity=logging.WARNING)
return None
log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
return registro
def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list) -> None:
"""
Rechaza un correo que no trae ningún comprobante válido: registra el motivo y
responde al remitente con la plantilla de rechazo (si las protecciones lo permiten).
"""
log(f"❌ Correo rechazado: {lista_obs[0].titulo}", severity=logging.WARNING)
registro = _registrar_correo(email)
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
if not registro or not token:
return
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_correo(email, registro, lista_obs), registro, sin_comprobante=True,
)
# ============================================================================
# PASO 1: Autenticación Microsoft Graph
# ============================================================================
@@ -146,14 +175,16 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
# 3. Descargar adjuntos
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
if not lista_adjuntos:
log("⏭️ Correo sin adjuntos")
log("⏭️ Correo sin adjuntos") # no se responde: no hubo intento de enviar un comprobante
return
n_recibidos = len(lista_adjuntos)
# 4. Descomprimir ZIPs
email_utilities.descomprimir_adjuntos([email], download_folder)
# 5. Filtrar PDF/XML y limpiar si no hay válidos
if not adjuntos.filtrar_y_limpiar(email, download_folder):
_rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)])
return
# 6. Procesar PDFs
@@ -163,6 +194,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
documentos = docs.procesar_xmls(email, download_folder, pdf_cache)
if not documentos:
log("⏭️ No se extrajeron documentos XML")
if any(a.lower().endswith(".xml") for a in email["adjuntos"]):
motivo = observaciones.sin_factura([])
else:
motivo = observaciones.falta_xml(sum(a.lower().endswith(".pdf") for a in email["adjuntos"]))
_rechazar_correo(email, token, email_addr, [motivo])
return
# 8. Completar guías faltantes desde PDF
@@ -181,6 +217,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
facturas = [d for d in documentos if d.get("tipo") == "01"]
if not facturas:
log("⏭️ No hay facturas (tipo 01) para SAP")
_rechazar_correo(email, token, email_addr,
[observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
for factura in facturas:
@@ -190,55 +228,89 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
estado = None # aceptado | rechazado | error
detalle_error = None
lista_obs: list = []
resultados_val: list = []
draft: dict = {}
validation_table = valor_referencial_table = ""
# 15. Crear draft en SAP o generar HTML
if todas_coinciden:
try:
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
if not todas_coinciden:
lista_obs = observaciones.desde_validacion(factura, resultados_val)
# "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
and not draft_sap.hana_disponible()):
detalle_error = "SAP HANA no responde: no se puede confirmar que las guías no existan"
except Exception as ex:
detalle_error = f"{type(ex).__name__}: {ex}"
# 15. Crear draft en SAP o generar el resultado
if detalle_error:
estado = "error"
log(f"❌ Error validando {serie}-{numero}: {detalle_error}", severity=logging.ERROR)
elif todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
estado = "aceptado"
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
estado, detalle_error = "error", str(ex)
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_sap", "detalle": str(ex)}])
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
else:
estado = "rechazado"
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
_registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}])
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 16. Enviar notificación al remitente original, con copia al equipo
if token:
destinatario = email.get("sender")
if destinatario and destinatario != "Desconocido":
outlook_utilities.send_email(
token, email_addr,
[destinatario],
f"Draft SAP B1 - {serie}-{numero}",
html_body, adjuntos_email,
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
)
else:
log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING)
if estado == "error":
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
if not token:
continue
respuesta_proveedor.enviar_a_equipo(
token, email_addr, f"Draft SAP B1 - {serie}-{numero}",
respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
)
if registro is None:
log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
elif estado == "aceptado":
respuesta_proveedor.responder(
token, email_addr, email, "aceptado",
respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
)
elif estado == "rechazado":
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
)
else:
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
# ============================================================================