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
@@ -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
|
||||
Reference in new issue
Block a user