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

+157
View File
@@ -0,0 +1,157 @@
"""
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 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