se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea

- 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>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 12:08:38 -05:00
1 parent 4deb9d4e4d
commit f0ad6aa78e
20 files changed
+991 -130

No files matched your search

+11 -6
View File
@@ -70,12 +70,15 @@ flowchart TD
O -- No --> RC
O -- Sí --> P[Bucle: por cada factura]
P --> Q[12. validate_prices]:::highlight
P --> P2{11.5 ¿Ya existe en SAP?}
P2 -- Sí --> V
P2 -- No --> Q[12. validate_prices]:::highlight
Q --> Q2[12.5 obtener_valor_referencial_sap]
Q2 --> R[13. build_sap_draft]:::highlight
R --> S[14. guardar_draft_json]
S --> T{¿Error de SAP/HANA?}
T -- Sí --> X[estado error]
S --> T{¿SAP/HANA caídos<br>u otro error?}
T -- Caída --> FL[estado fuera de línea:<br>aviso 'En cola' una vez<br>y el correo se reintenta]
T -- Otro error --> X[estado error]
T -- No --> T2{Todas coinciden?}
T2 -- Sí --> U[15. sapb1_manager.create_draft]
T2 -- No --> V[estado rechazado + observaciones]
@@ -83,6 +86,7 @@ flowchart TD
U2 --> W[16. Correo técnico al equipo<br>+ respuesta con plantilla al remitente]
V --> W
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
FL --> P
W --> P
W2 --> P
@@ -193,12 +197,13 @@ run_pipeline
| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
|-------------------------|-----------------------------------------------|-------------------------|
| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
| `rechazado` (precio, guía…) | Equipo | Rechazado con observaciones, al remitente |
| `error` (SAP/HANA, fallo inesperado) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| `rechazado` (precio, guía, o la factura **ya existe en SAP**) | Equipo | Rechazado con observaciones, al remitente |
| `fuera_de_linea` (SAP o HANA no responden) | Equipo (primer intento y al agotarse) | "En cola", **una sola vez**; el correo se reintenta |
| `error` (fallo inesperado, rechazo normal de SAP) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| Correo sin comprobante válido (sin PDF/XML, sin XML, sin factura) | — | Rechazado sin comprobante, al remitente |
| Correo sin adjuntos | — | Ninguna |
"Al remitente" depende de `NOTIFICACION_MODO` (en modo `prueba` va al equipo) y de las protecciones. Todo en [respuesta_proveedor.md](respuesta_proveedor.md).
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente.
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente. La lógica por factura vive en `_procesar_factura`; `_procesar_un_correo` la llama una vez por factura y, si alguna pide reintento, deja el correo en cola.
+28 -5
View File
@@ -10,13 +10,35 @@ Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observacione
|-----------|------------------------|
| Draft creado en SAP | **Aceptado** |
| Precios que no coinciden, guía inexistente, línea sin guía, cantidad cero | **Rechazado** con una observación por motivo |
| La factura **ya existe en SAP** (factura de compra no anulada o draft) | **Rechazado**: "Esta factura ya está registrada". No se crea otro draft |
| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
| Error de SAP o de HANA, o un fallo inesperado | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| **SAP o HANA no responden** | **Aviso "En cola"** (una sola vez): se reintentará solo, no hace falta reenviar. Ver más abajo |
| Fallo inesperado, o un rechazo normal de SAP al crear el draft (HTTP 4xx) | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| Correo sin adjuntos | **Nada** |
Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
> **Guía "no encontrada" y caídas de HANA.** `get_delivery_notes` devuelve lista vacía tanto si la guía no existe como si SAP/HANA fallan. Antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, se trata como error interno y no se le dice nada al proveedor.
> **Guía "no encontrada" y caídas.** `get_delivery_notes` ya no confunde una caída con una guía inexistente: si Service Layer no responde y HANA no aporta nada, lanza `SapNoDisponible`. Además, antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, es "servicio fuera de línea" y no un rechazo.
## ¿La factura ya existe en SAP?
Antes de validar precios, `sapb1_manager.buscar_factura_existente` busca el comprobante por proveedor (`PL` + RUC), tipo, serie y número (`U_BPP_MDTD`/`U_BPP_MDSD`/`U_BPP_MDCD`) en las **facturas de compra no anuladas** y en los **drafts** de factura de compra. Si existe, no se crea otro draft y se responde con la observación `factura_ya_registrada`. Si el comprobante ya estaba **aceptado** en `comprobantes`, ese registro (estado y `doc_entry`) no se sobrescribe.
> La plantilla de rechazo dice "No pudimos registrar su factura… Corríjalas y envíenos el comprobante nuevamente", que no encaja del todo con un duplicado: la observación aclara que no hace falta reenviarla. Si prefiere una plantilla propia para este caso, es un cambio pequeño.
## Servicio fuera de línea y reintentos
`SapNoDisponible` (subclase de `ConnectionError`) se lanza solo ante una **caída**: timeout, sin conexión, HTTP 5xx o login imposible por esos motivos. Un HTTP 4xx (rechazo de SAP) o credenciales inválidas siguen siendo un `ConnectionError` normal y **no** se tratan como caída.
Cuando una factura se encuentra con una caída:
1. Se suma un intento en `comprobantes.intentos` y el resultado queda `error` con la observación `servicio_no_disponible`.
2. **Solo en el primer intento** se envía al proveedor la plantilla `plantilla-fuera-de-linea.html` ("En cola, la procesaremos automáticamente, no necesita reenviarla") y se avisa al equipo. La notificación queda `en_espera`. Los reintentos siguientes **no repiten** ningún aviso.
3. El correo se **elimina de `emails`**, de modo que el siguiente ciclo del demonio lo vuelve a tomar.
4. Cuando SAP vuelve, se procesa con normalidad y el proveedor recibe el aceptado o rechazado.
5. Tras `MAX_REINTENTOS` (24) intentos se deja de reintentar, se avisa al equipo ("Reintentos agotados") y el proveedor no recibe nada más.
Al reintentar un correo, lo que ya se resolvió dentro de él (otra factura del mismo correo) no se repite ni se responde dos veces. Sin seguimiento (si falla la base de datos) no se puede contar intentos, así que una caída se trata como error interno y no se reintenta.
## Modo prueba y modo real
@@ -62,6 +84,7 @@ Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar
| `guia_no_encontrada` | SAP no devuelve datos para la guía |
| `linea_sin_guia` | Una línea no indica su guía de remisión |
| `cantidad_cero` | Una línea tiene cantidad cero |
| `factura_ya_registrada` | La factura ya existe en SAP |
| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
| `falta_xml` | Hay PDF y ningún XML |
| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
@@ -85,8 +108,8 @@ El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el compr
## Limitaciones conocidas
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado.
- **Si SAP está caído, el correo tampoco se reintenta.** Se avisa al equipo y el correo queda procesado (con estado `error`).
- **Un comprobante ya aceptado que se reenvía** genera otro draft y otra respuesta "Aceptado"; hoy solo se advierte en el log.
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado. (Solo se reintentan los correos cuando SAP está caído.)
- Los reintentos de SAP caído dependen de que el correo siga dentro de la ventana `--dias` del demonio (2 días por defecto).
- Un error que no es una caída (por ejemplo un HTTP 400 de SAP al crear el draft) no se reintenta: se avisa al equipo.
- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
+6 -3
View File
@@ -21,7 +21,8 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `observaciones` | JSON con los motivos de rechazo o el detalle del error |
| `doc_entry` | DocEntry del draft creado en SAP |
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida`, `omitida` o `en_espera` (se avisó que el servicio está fuera de línea), y su motivo |
| `intentos` | Veces que se intentó procesar el comprobante con SAP fuera de línea (tope: `MAX_REINTENTOS`) |
| `notificacion_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) |
| `creado_en`, `actualizado_en` | Marcas de tiempo |
@@ -34,6 +35,7 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `obtener(codigo)` | Devuelve el registro o `None`. |
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. |
| `registrar_intento(codigo)` | Suma un intento fallido por SAP fuera de línea y devuelve el total. |
| `contar_respuestas_correo(remitente, horas=24)` | Respuestas `enviada` a ese remitente en correos sin comprobante: el freno contra bucles de `respuesta_proveedor`. |
## Uso en el pipeline
@@ -46,7 +48,7 @@ En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_regist
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
`init_db` agrega la columna `notificacion_destinatario` a una tabla creada antes sin ella, sin tocar los datos.
`init_db` agrega las columnas `notificacion_destinatario` e `intentos` a una tabla creada antes sin ellas, sin tocar los datos.
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
@@ -54,11 +56,12 @@ El seguimiento **nunca impide procesar la factura**: si falla la base de datos,
- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
- Error: `[{"codigo": "error_interno", "detalle": "..."}]`.
- SAP fuera de línea: `[{"codigo": "servicio_no_disponible", "detalle": "...", "intentos": n}]`.
- Correo sin comprobante: la observación del motivo (`sin_pdf_xml`, `falta_xml`, `sin_factura`).
Las notificaciones las registra `respuesta_proveedor` (`enviada`, `fallida` u `omitida` con su motivo).
## Notas
- `database_utilities.DB_NAME` es `"emails.db"`, una ruta **relativa al directorio de trabajo**. Si el demonio se lanza desde otra carpeta, usará (y creará) otra base y los códigos ya emitidos no se encontrarían. Hoy funciona porque se ejecuta desde la raíz del proyecto.
- `database_utilities.DB_NAME` es `emails.db` **junto al código** (ruta absoluta): no depende del directorio desde el que se lance el demonio, por ejemplo como servicio de Windows.
- Las pruebas redirigen `DB_NAME` a una base temporal (fixture automático en `tests/conftest.py`) para no escribir en `emails.db`.