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:
1 parent
af9e3961e5
commit
4deb9d4e4d
22 files changed
+1660
-146
No files matched your search
+104
-32
@@ -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")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in new issue
Block a user