diff --git a/database_utilities.py b/database_utilities.py
index 666e795..180a507 100644
--- a/database_utilities.py
+++ b/database_utilities.py
@@ -111,4 +111,6 @@ def delete_email_by_id(email_id: str) -> bool:
conn.close()
return rows_affected > 0
-# ============================================================================
\ No newline at end of file
+# ============================================================================
+if __name__ == "__main__":
+ delete_email_by_id("AAMkADljMzNhYmQwLTQyMjgtNDY2My05ZDUwLWEzNTBiNDZhZjFlMwBGAAAAAAB386nfKhkcTbYV6pPUJ245BwBdNWALkxgeSJ3BdBRukthBAAAAAAEMAABdNWALkxgeSJ3BdBRukthBAAAtOXesAAA=")
\ No newline at end of file
diff --git a/docs/inventario.md b/docs/inventario.md
new file mode 100644
index 0000000..8cc9e07
--- /dev/null
+++ b/docs/inventario.md
@@ -0,0 +1,95 @@
+# Inventario del código
+
+## Archivos
+
+| Archivo | Líneas | Rol |
+|---------|--------|-----|
+| `main.py` | 134 | Demonio orquestador (loop de horarios) |
+| `pipeline.py` | 957 | Pipeline core (procesar un correo completo) |
+| `sunat_schema.py` | 521 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
+| `pdf_utilities.py` | 450 | Parseo de PDFs (factura, guías) |
+| `sapb1_manager.py` | 322 | Comunicación con SAP B1 Service Layer |
+| `outlook_utilities.py` | 228 | Comunicación con Microsoft Graph |
+| `hana_manager.py` | 126 | Comunicación con SAP HANA directa |
+| `database_utilities.py` | 114 | SQLite local (cache de correos) |
+| `consulta_rapida.py` | 115 | Script auxiliar para reprocesar correos por ID |
+| `email_utilities.py` | 93 | Utilitarios de adjuntos (ZIP, filtrado) |
+| `logger_utilities.py` | 72 | Logging a archivo + consola |
+
+---
+
+## Dependencias entre archivos
+
+```
+main.py
+ └── pipeline.py
+ ├── sunat_schema.py
+ ├── pdf_utilities.py
+ ├── sapb1_manager.py
+ │ └── hana_manager.py
+ ├── outlook_utilities.py
+ ├── email_utilities.py
+ │ └── database_utilities.py
+ └── database_utilities.py
+```
+
+---
+
+## Flujo de datos
+
+```
+main()
+ └── run_pipeline()
+ ├── step_authenticate() → token MS Graph
+ ├── step_fetch_emails() → emails[]
+ └── _procesar_un_correo() (por cada email)
+ ├── BD: verificar duplicado
+ ├── _descargar_adjuntos() → archivos
+ ├── _descomprimir() ZIPs
+ ├── _filtrar_y_limpiar() → solo PDF/XML
+ ├── _procesar_pdfs() → pdf_cache
+ │ └── pdf_utilities.extraer_datos_pdf()
+ ├── _procesar_xmls(pdf_cache) → documentos[]
+ │ └── sunat_schema.extraer_datos_xml()
+ ├── _guardar_resultado(documentos)
+ ├── _validate_prices(factura) → (ok, resultados)
+ │ └── sapb1_manager.get_delivery_notes()
+ │ ├── SL (Service Layer) GET /DeliveryNotes
+ │ └── HANA fallback (get_delivery)
+ ├── _build_sap_draft(factura, resultados) → draft
+ ├── _guardar_draft_json(draft)
+ ├── sapb1_manager.create_draft(draft)
+ └── outlook_utilities.send_email(notificación)
+```
+
+---
+
+## Problemas conocidos hasta ahora
+
+| # | Problema | Archivo | Estado |
+|---|----------|---------|--------|
+| 1 | `_extraer_serie_numero_desde_texto` no limpiaba leading zeros | `sunat_schema.py` | ✅ Corregido |
+| 2 | Al asignar guías por línea, si hay +refs que líneas, las sobrantes se perdían | `pipeline.py` | ✅ Corregido |
+| 3 | SL sin fallback a HANA | `sapb1_manager.py` | ✅ Corregido |
+| 4 | Ctrl+C mostraba traceback | `main.py` | ✅ Corregido |
+| 5 | `.env.dev` vs `.env` inconsistente | `sapb1_manager.py` | ✅ Corregido |
+| 6 | `HANA_HOST` vs `HANA_SERVER` en `.env` | `hana_manager.py` | ✅ Corregido |
+| 7 | Columna `Cancelled` vs `CANCELED` en HANA | `hana_manager.py` | ✅ Corregido |
+
+---
+
+## Funciones internas en `_procesar_un_correo` (pipeline.py)
+
+| Función | Líneas | Propósito |
+|---------|--------|-----------|
+| `_descargar_adjuntos` | 107-134 | Descarga adjuntos vía Graph API |
+| `_descomprimir` | 136-149 | Descomprime ZIPs |
+| `_filtrar_y_limpiar` | 151-191 | Filtra PDF/XML, elimina el resto |
+| `_procesar_pdfs` | 193-246 | Extrae datos de PDFs, renombra, genera cache |
+| `_procesar_xmls` | 248-377 | Procesa XMLs, cruza con PDF, asigna guías |
+| `_guardar_resultado` | 379-408 | Guarda JSON con datos extraídos |
+| `_build_sap_draft` | 410-569 | Construye payload del draft SAP B1 |
+| `_validate_prices` | 571-745 | Valida precios contra DeliveryNotes SAP |
+| `_build_validation_html` | 747-775 | Genera tabla HTML para notificación |
+| `_build_notification_body` | 777-814 | Arma cuerpo del email de notificación |
+| `_guardar_draft_json` | 816-842 | Guarda draft JSON para depuración |
diff --git a/docs/main.md b/docs/main.md
new file mode 100644
index 0000000..0dec9b5
--- /dev/null
+++ b/docs/main.md
@@ -0,0 +1,92 @@
+# main.py — Mapa del flujo
+
+## Estructura general
+
+```
+main.py
+├── Constantes: HORARIOS (4 bloques horarios)
+├── obtener_intervalo(dt) → (intervalo, modo) | None
+├── segundos_hasta_proximo_inicio(dt) → int
+├── non_negative_int(value) → int (validador argparse)
+└── main() → None
+```
+
+## Proceso de `main()`
+
+```mermaid
+flowchart TD
+ A[Inicio main] --> B[Parsear args --dias]
+ B --> C[Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER]
+ C --> D{Bucle infinito}
+
+ D --> E[inicio_ciclo = datetime.now]
+ E --> F[obtener_intervalo inicio_ciclo]
+
+ F -- Sí, está en horario --> G[Extraer intervalo, modo]
+ G --> H[run_pipeline]:::highlight
+ H --> I[Calcular duración real]
+ I --> J{Duración > intervalo?}
+ J -- Sí --> K[⚠️ Warning]
+ J -- No --> L[dormir = intervalo - duración]
+ K --> L
+ L --> M[💤 Próximo ciclo en dormir seg]
+ M --> N[time.sleep dormir]
+ N --> E
+
+ F -- No, fuera de horario --> O[dormir = segundos_hasta_proximo_inicio]
+ O --> P[Log próxima ejecución]
+ P --> N
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+### Paso a paso
+
+```
+main()
+│
+├── 1. Parsear args (--dias, default=2)
+│
+├── 2. Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER
+│
+├── 3. LOOP INFINITO (while True):
+│ │
+│ ├── 3.1. inicio_ciclo = datetime.now()
+│ │
+│ ├── 3.2. resultado = obtener_intervalo(inicio_ciclo)
+│ │ │
+│ ├── ¿Sí está en horario?
+│ │ │
+│ │ ├── Extraer (intervalo, modo)
+│ │ ├── run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
+│ │ ├── Calcular duración real del ciclo
+│ │ ├── ⚠️ Si duración > intervalo → warning
+│ │ └── dormir = max(0, intervalo - duración)
+│ │
+│ └── ¿No está en horario?
+│ │
+│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente)
+│ └── Log "Fuera del horario, próxima ejecución en..."
+│
+└── 4. time.sleep(dormir) → vuelve a 3.1
+```
+
+## Bloques horarios definidos
+
+| Desde | Hasta | Intervalo | Modo |
+|---------|---------|-----------|----------|
+| 06:30 | 13:15 | 300s | rápido |
+| 13:15 | 14:00 | 1800s | full |
+| 14:00 | 18:30 | 300s | rápido |
+| 18:30 | 21:30 | 3600s | full |
+
+Fuera de ese rango → duerme hasta las 06:30 del día siguiente.
+
+## Entry point (línea 121-130)
+
+```
+if __name__ == "__main__":
+ 1. logger_utilities.setup_logger()
+ 2. logger_utilities.limpiar_logs_antiguos(dias=30)
+ 3. main()
+```
diff --git a/docs/pipeline.md b/docs/pipeline.md
new file mode 100644
index 0000000..dffe07d
--- /dev/null
+++ b/docs/pipeline.md
@@ -0,0 +1,114 @@
+# pipeline.py — run_pipeline
+
+## Estructura general
+
+```
+pipeline.py
+├── CONSTANTES: CUADRO_VALOR_REFERENCIAL
+├── HELPERS: _ruta_carpeta()
+├── PASO 1: step_authenticate() → token | None
+├── PASO 2: step_fetch_emails() → list[dict]
+├── _procesar_un_correo() → None (core)
+└── run_pipeline() → None
+```
+
+---
+
+## Proceso de `run_pipeline()`
+
+```mermaid
+flowchart TD
+ A[run_pipeline] --> B[init_db]
+ B --> C[Calcular rango fechas: ayer-dias → hoy]
+ C --> D[step_authenticate]
+ D --> E{Token obtenido?}
+ E -- No --> F[❌ Abortar]
+ E -- Sí --> G[step_fetch_emails]
+ G --> H{Hay correos?}
+ H -- No --> I[Fin]
+ H -- Sí --> J[Bucle: por cada email]
+ J --> K[_procesar_un_correo]:::highlight
+ K --> J
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+---
+
+## Proceso de `_procesar_un_correo()`
+
+```mermaid
+flowchart TD
+ A[_procesar_un_correo] --> B[1-2. BD: ¿ya procesado?]
+ B -- Sí --> C[⏭️ Saltar]
+ B -- No --> D[Guardar en BD]
+ D --> E[3. _descargar_adjuntos]
+ E --> F{Hay adjuntos?}
+ F -- No --> C
+ F -- Sí --> G[4. _descomprimir ZIPs]
+ G --> H[5. _filtrar_y_limpiar]
+ H --> I{Hay PDF/XML?}
+ I -- No --> C
+ I -- Sí --> J[6. _procesar_pdfs]:::highlight
+ J --> K[7. _procesar_xmls]:::highlight
+ K --> L{Hay documentos?}
+ L -- No --> C
+ L -- Sí --> M[8. _guardar_resultado]
+ M --> N[9. Filtrar facturas tipo 01]
+ N --> O{Hay facturas?}
+ O -- No --> C
+ O -- Sí --> P[Bucle: por cada factura]
+
+ P --> Q[10. _validate_prices]:::highlight
+ Q --> R[11. _build_sap_draft]:::highlight
+ R --> S[12. _guardar_draft_json]
+ S --> T{Todas coinciden?}
+ T -- Sí --> U[13. sapb1_manager.create_draft]
+ T -- No --> V[⏭️ Draft NO creado]
+ U --> W[14. outlook_utilities.send_email]
+ V --> W
+ W --> P
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+---
+
+## Funciones internas de `_procesar_un_correo`
+
+| Función | Línea | Propósito |
+|---------|-------|-----------|
+| `_descargar_adjuntos()` | 107 | Descarga adjuntos vía Microsoft Graph |
+| `_descomprimir()` | 136 | Descomprime archivos ZIP |
+| `_filtrar_y_limpiar()` | 151 | Filtra solo PDF/XML, elimina el resto |
+| **`_procesar_pdfs()`** | **193** | **Extrae datos de PDFs, renombra, genera cache** |
+| **`_procesar_xmls(pdf_cache)`** | **248** | **Procesa XMLs, cruza con PDF, asigna guías** |
+| `_guardar_resultado(documentos)` | 372 | Guarda JSON con datos extraídos |
+| **`_build_sap_draft(factura, resultados_val)`** | **403** | **Construye payload del draft SAP B1** |
+| **`_validate_prices(factura)`** | **564** | **Valida precios contra DeliveryNotes SAP** |
+| `_build_validation_html(resultados)` | 741 | Genera tabla HTML para notificación |
+| `_build_notification_body(status, factura, validation_table)` | 771 | Arma cuerpo del email de notificación |
+| `_guardar_draft_json(draft, serie, numero)` | 810 | Guarda draft JSON para depuración |
+
+---
+
+## Flujo de llamadas
+
+```
+run_pipeline
+ ├── step_authenticate
+ ├── step_fetch_emails
+ └── _procesar_un_correo (por cada email)
+ ├── _descargar_adjuntos
+ ├── _descomprimir
+ ├── _filtrar_y_limpiar
+ ├── _procesar_pdfs
+ ├── _procesar_xmls
+ ├── _guardar_resultado
+ ├── _validate_prices (por factura)
+ │ └── sapb1_manager.get_delivery_notes
+ ├── _build_sap_draft
+ ├── _guardar_draft_json
+ ├── sapb1_manager.create_draft
+ └── outlook_utilities.send_email
+```
diff --git a/docs/pipeline_completo.mkd b/docs/pipeline_completo.mkd
deleted file mode 100644
index e750269..0000000
--- a/docs/pipeline_completo.mkd
+++ /dev/null
@@ -1,29 +0,0 @@
-# Pipeline Completo
-
-```mermaid
-flowchart TD
- A["📬 Correo recibido"] --> B{"1-2. BD check
¿Ya procesado?"}
- B -->|"Sí"| C["⏭️ Saltar correo"]
- B -->|"No"| D["3. Descargar adjuntos
Microsoft Graph"]
- D --> E{"¿Hay adjuntos?"}
- E -->|No| C
- E -->|Sí| F["4. Descomprimir ZIPs"]
- F --> G["5. Filtrar solo PDF/XML"]
- G --> H{"¿Quedan válidos?"}
- H -->|No| C
- H -->|Sí| I["6. Procesar PDFs
extraer datos + renombrar"]
- I --> J["7. Procesar XMLs
cruzar con PDF cache"]
- J --> K["8. Guardar resultado JSON"]
- K --> L["9. Filtrar facturas tipo 01"]
- L --> M{"¿Hay facturas?"}
- M -->|No| N["⏭️ Sin facturas para SAP"]
- M -->|Sí| O["10. Validar precios
_validate_prices()"]
- O --> P["11. Construir draft SAP
_build_sap_draft()"]
- P --> Q["12. Guardar draft JSON"]
- Q --> R{"13. ¿Precios coinciden?"}
- R -->|Sí| S["Crear Draft en SAP B1
POST /Drafts"]
- R -->|No| T["Notificar: validación falló"]
- S --> U["14. Enviar email notificación"]
- T --> U
- N --> U
-```
diff --git a/docs/procesar_pdfs.md b/docs/procesar_pdfs.md
new file mode 100644
index 0000000..ea6363c
--- /dev/null
+++ b/docs/procesar_pdfs.md
@@ -0,0 +1,262 @@
+# _procesar_pdfs — Extracción de datos desde PDFs
+
+## Ubicación
+
+- **Archivo:** `pipeline.py` (línea 193-246)
+- **Librería:** `pdf_utilities.py` (línea 1-450)
+
+---
+
+## Flujo general
+
+```mermaid
+flowchart TD
+ A[_procesar_pdfs] --> B[Iterar adjuntos del email]
+ B --> C{Es .pdf?}
+ C -- No --> B
+ C -- Sí --> D[extraer_datos_pdf]:::highlight
+ D --> E{Hay datos sin error?}
+ E -- No --> F[⚠️ Log error]
+ F --> B
+ E -- Sí --> G[renombrar_pdf]:::highlight
+ G --> H[Actualizar email.adjuntos]
+ H --> I[Acumular en datos_pdf]
+ I --> J[Guardar en pdf_cache]
+ J --> B
+
+ B -- Fin --> K{datos_pdf vacío?}
+ K -- No --> L[email.datos_pdf = datos_pdf]
+ K -- Sí --> M[return pdf_cache]
+ L --> M
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+---
+
+## Paso a paso
+
+```
+_procesar_pdfs() → dict (pdf_cache)
+│
+├── carpeta = _ruta_carpeta(download_folder)
+│
+├── Por cada adjunto en email["adjuntos"]:
+│ │
+│ ├── ¿No termina en ".pdf"? → continue
+│ │
+│ ├── pdf_path = carpeta + adjunto
+│ │
+│ ├── extraer_datos_pdf(pdf_path) → data
+│ │ └── (ver detalle abajo)
+│ │
+│ ├── ¿data válido y sin error?
+│ │ │
+│ │ ├── renombrar_pdf(pdf_path, data) → nuevo_path
+│ │ │ └── formato: {ruc}-{tipo}-{serie}-{numero}.pdf
+│ │ │
+│ │ ├── Si cambió el nombre:
+│ │ │ ├── email["adjuntos"][idx] = nuevo_nombre
+│ │ │ └── data["archivo"] = nuevo_nombre
+│ │ │
+│ │ ├── datos_pdf.append(data)
+│ │ ├── pdf_cache[canonical] = data
+│ │ └── 📄 PDF procesado: {canonical}
+│ │
+│ └── ¿Error? → ⚠️ PDF no procesado: {error}
+│
+├── Si hay datos → email["datos_pdf"] = datos_pdf
+│
+└── return pdf_cache
+```
+
+---
+
+## `extraer_datos_pdf(pdf_path)` `:highlight` — Detalle
+
+```mermaid
+flowchart TD
+ A[extraer_datos_pdf] --> B[pdfplumber.open]
+ B --> C[Extraer texto de todas las páginas]
+ C --> D[_extract_base_data]:::highlight
+ D --> E{Qué tipo de documento?}
+
+ E -- FACTURA --> F[_extract_factura_details]:::highlight
+ F --> G[_extract_detraccion_spatial]
+
+ E -- GUIA DE REMISION --> H[_extract_guia_remision_details]:::highlight
+
+ E -- GUIA DE REMISION TRANSPORTISTA --> I[_extract_guia_transportista_details]:::highlight
+
+ E -- BOLETA / otro --> J[return data]
+
+ G --> J
+ H --> J
+ I --> J
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+### `_extract_base_data(text, path)` — Datos base
+
+Extrae del texto plano del PDF mediante regex:
+
+| Campo | Fuente |
+|-------|--------|
+| `ruc_emisor` | `R.U.C. {11 dígitos}` o nombre del archivo o primer RUC en el texto |
+| `tipo_documento` | "FACTURA ELECTRÓNICA" / "BOLETA DE VENTA" / "GUÍA DE REMISIÓN" / "GUÍA DE REMISIÓN TRANSPORTISTA" |
+| `serie_numero` | `{4 caracteres} - {números}` ej: `F002-486` |
+| `fecha_emision` | `dd/mm/yyyy` |
+| `ruc_receptor` | Segundo RUC de 11 dígitos encontrado |
+| `nombre_receptor` | Línea con `SEÑOR(ES) / CLIENTE / RAZÓN SOCIAL / ADQUIRIENTE` |
+| `monto_total` | `TOTAL / IMPORTE TOTAL / NETO A PAGAR` |
+
+---
+
+### `_extract_factura_details(text, data)` `:highlight` — Factura
+
+```mermaid
+flowchart TD
+ A[_extract_factura_details] --> B[Buscar monto_total]
+ B --> C[Inicializar items[] y detraccion]
+ C --> D[Buscar cabecera de tabla]
+ D --> E[Parsear filas de items]
+ E --> F[Por cada item extraer:]
+ F --> G[guias_remision]
+ F --> H[guia_transportista]
+ F --> I[bolsas]
+ F --> J[peso / unidad_peso]
+ J --> K[Buscar detraccion]
+ K --> L[return]
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+**Items de tabla** (detecta ≥3 headers entre: CANT, DESCRIP, PRECIO, TOTAL, UNIT, VALOR, CÓDIGO, ITEM):
+
+| Campo | Descripción |
+|-------|-------------|
+| `codigo` | Código alfanumérico del producto (opcional) |
+| `cantidad` | Cantidad numérica |
+| `descripcion` | Descripción del ítem |
+| `precio_unitario` | Precio unitario |
+| `total` | Total por línea |
+
+**Por cada item**, parsea desde la descripción:
+
+| Campo | Regex |
+|-------|-------|
+| `guias_remision[]` | `GRR / GR / G.R.R. / GUIA REMISION: {serie}-{num}` |
+| `guia_transportista` | `GRT / G.R.T. / GUIA TRANSPORTISTA: {serie}-{num}` |
+| `bolsas` | `{n} BOLSAS / BLS / SACOS` |
+| `peso` / `unidad_peso` | `PESO: {valor} {TN/KG}` |
+
+**Detracción**: Detecta si tiene detracción, porcentaje y monto.
+
+---
+
+### `_extract_guia_remision_details(text, data)` `:highlight` — Guía de Remisión
+
+Extrae del texto:
+
+| Campo | Descripción |
+|-------|-------------|
+| `peso_total` | Peso Total |
+| `punto_partida` | Punto de Partida |
+| `punto_llegada` | Punto de Llegada |
+| `placa` | Placa del vehículo |
+| `marca` | Marca del vehículo |
+| `chofer` | Nombre del chofer |
+| `brevete` | N° de brevete |
+| `configuracion_vehicular` | Configuración vehicular |
+| `items[]` | Lista de {cantidad, unidad, descripcion} |
+
+---
+
+### `_extract_guia_transportista_details(text, data)` `:highlight` — Guía de Remisión Transportista
+
+Extrae del texto:
+
+| Campo | Descripción |
+|-------|-------------|
+| `punto_partida` | Punto de Partida |
+| `punto_llegada` | Punto de Llegada |
+| `nombre_remitente` / `ruc_remitente` | Datos del remitente |
+| `nombre_destinatario` / `ruc_destinatario` | Datos del destinatario |
+| `documentos_relacionados[]` | Documentos relacionados |
+| `peso_bruto_total_carga` | Peso bruto total |
+| `unidad_medida_peso_bruto` | Unidad de medida |
+| `indicador_pagador_flete` | Indicador del pagador |
+| `nombre_pagador` / `ruc_pagador` | Datos del pagador |
+| `datos_vehiculos[]` | Lista de {placa} |
+| `datos_conductores[]` | Lista de {nombre, brevete} |
+| `observaciones` | Observaciones |
+
+---
+
+## `renombrar_pdf(pdf_path, data)` `:highlight`
+
+```
+renombrar_pdf(pdf_path, data) → nuevo_path
+│
+└── generar_nombre_pdf(data) → nombre canónico
+ │
+ ├── ruc = data["ruc_emisor"]
+ ├── abrev = TIPO_MAP[data["tipo_documento"]]
+ │ └── FACTURA → "01", BOLETA → "03",
+ │ GUIA DE REMISION → "09", GUIA DE REMISION TRANSPORTISTA → "31"
+ ├── serie, numero = data["serie_numero"].split("-")
+ │ └── numero = numero.lstrip("0") → elimina ceros a la izquierda
+ │
+ └── "{ruc}-{abrev}-{serie}-{numero}.pdf"
+ ej: "20537929520-01-F002-486.pdf"
+
+└── os.rename(origen, destino)
+```
+
+---
+
+## Tipos de documento detectados
+
+| Tipo detectado | Código | Sección extraída |
+|----------------|--------|------------------|
+| FACTURA ELECTRÓNICA | 01 | Items, detracción, guías de remisión |
+| BOLETA DE VENTA | 03 | Solo datos base |
+| GUÍA DE REMISIÓN | 09 | Peso, destino, vehículo, items |
+| GUÍA DE REMISIÓN TRANSPORTISTA | 31 | Remitente, destinatario, vehículos, conductores |
+
+---
+
+## Salida
+
+```python
+# pdf_cache → dict
+{
+ "20537929520-01-F002-486.pdf": {
+ "archivo": "20537929520-01-F002-486.pdf",
+ "ruc_emisor": "20537929520",
+ "tipo_documento": "FACTURA",
+ "serie_numero": "F002-486",
+ "fecha_emision": "08/07/2026",
+ "ruc_receptor": "20123456789",
+ "nombre_receptor": "EMPRESA SAC",
+ "monto_total": "4680.00",
+ "items": [
+ {
+ "cantidad": "350",
+ "descripcion": "FLETE T001-127773 PESO: 23.26 TN GRR: T001-127773",
+ "precio_unitario": "1.3396",
+ "total": "468.86",
+ "guias_remision": ["T001-127773"],
+ "guia_transportista": None,
+ "bolsas": None,
+ "peso": "23.26",
+ "unidad_peso": "TN"
+ }
+ ],
+ "detraccion": {"tiene": True, "porcentaje": "4.00", "monto": "187.20"}
+ }
+}
+
+# email["datos_pdf"] → list[dict] (misma estructura)
+```
diff --git a/docs/procesar_xmls.md b/docs/procesar_xmls.md
new file mode 100644
index 0000000..8fa04e1
--- /dev/null
+++ b/docs/procesar_xmls.md
@@ -0,0 +1,257 @@
+# _procesar_xmls — Extracción y cruce de datos desde XML
+
+## Ubicación
+
+- **Archivo:** `pipeline.py` (línea 248-370)
+- **Librería:** `sunat_schema.py` (línea 1-520)
+
+---
+
+## Flujo general
+
+```mermaid
+flowchart TD
+ A[_procesar_xmls] --> B[Iterar adjuntos del email]
+ B --> C{Es .xml?}
+ C -- No --> B
+ C -- Sí --> D[extraer_datos_xml]:::highlight
+ D --> E{Hay datos?}
+ E -- No --> F[ℹ️ XML sin datos]
+ F --> B
+ E -- Sí --> G[Construir nombre PDF canónico]
+ G --> H[Buscar PDF en cache o fallback]
+ H --> I[Cruzar documentos_referencia desde PDF]:::highlight
+ I --> J[Cruzar guías de remisión por línea]:::highlight
+ J --> K[Agregar a lista de documentos]
+ K --> B
+
+ B -- Fin --> L[return documentos]
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+---
+
+## Paso a paso
+
+```
+_procesar_xmls(pdf_cache) → list[dict]
+│
+├── carpeta = _ruta_carpeta(download_folder)
+│
+├── Por cada adjunto en email["adjuntos"]:
+│ │
+│ ├── ¿No termina en ".xml"? → continue
+│ │
+│ ├── xml_path = carpeta + adjunto
+│ │
+│ ├── sunat_schema.extraer_datos_xml(xml_path) → data | None
+│ │ └── Solo procesa Invoice tipo 01 y DespatchAdvice tipo 31
+│ │
+│ ├── ¿data es None? → ℹ️ XML sin datos, continue
+│ │
+│ ├── data["ruta_xml"] = xml_path
+│ │
+│ ├── Construir nombre PDF canónico:
+│ │ └── "{ruc_emisor}-{tipo}-{serie}-{numero}.pdf"
+│ │
+│ ├── Buscar PDF asociado:
+│ │ ├── 1. pdf_cache por nombre canónico
+│ │ └── 2. Fallback: mismo nombre con extensión .pdf
+│ │ └── data["ruta_pdf"] = ruta encontrada o None
+│ │
+│ ├── ¿data sin documentos_referencia?
+│ │ ├── Buscar en pdf_cache por nombre canónico
+│ │ │ └── pdf_utilities.extraer_referencias(items) → refs
+│ │ ├── Si no → fallback: buscar en email["datos_pdf"]
+│ │ └── Si encuentra → data["documentos_referencia"] = refs
+│ │
+│ ├── Asignar guías de remisión por línea:
+│ │ ├── refs_09 = [tipo 09] desde documentos_referencia
+│ │ ├── refs_31 = [tipo 31] desde documentos_referencia
+│ │ ├── pdf_items = pdf_cache[canonical].items[] (si existe)
+│ │ │
+│ │ └── Por cada línea en data["lineas"]:
+│ │ │
+│ │ ├── ¿Hay PDF items para esta línea?
+│ │ │ ├── Parsear guias_remision[] desde descripción PDF
+│ │ │ │ └── Regex: "{serie}-{numero}" con prefijo T/V/E
+│ │ │ └── Parsear guia_transportista desde descripción PDF
+│ │ │
+│ │ ├── ¿Hay refs_09 para esta línea?
+│ │ │ └── Asignar desde documentos_referencia ordenados
+│ │ │
+│ │ └── No → guias_remision = [], guia_transportista = None
+│ │
+│ └── ✅ Agregar data a documentos[]
+│
+└── return documentos
+```
+
+---
+
+## `extraer_datos_xml(xml_path)` `:highlight`
+
+### Flujo de decisión
+
+```mermaid
+flowchart TD
+ A[extraer_datos_xml] --> B[ET.parse xml]
+ B --> C{Tag raíz?}
+
+ C -- Invoice --> D{tipo == 01?}
+ D -- Sí --> E[_parse_invoice]:::highlight
+ D -- No --> F[return None]
+
+ C -- DespatchAdvice --> G{tipo == 31?}
+ G -- Sí --> H[_parse_despatch_advice]:::highlight
+ G -- No --> F
+
+ C -- Otro --> F
+
+ E --> I[return datos.to_dict]
+ H --> I
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+Solo procesa **Facturas tipo 01** y **Guías de Remisión Transportista tipo 31**.
+
+---
+
+### `_parse_invoice(root)` — Factura tipo 01
+
+```mermaid
+flowchart TD
+ A[_parse_invoice] --> B[ID → serie, numero]
+ B --> C[IssueDate → fecha_emision]
+ C --> D[InvoiceTypeCode → tipo]
+ D --> E[AccountingSupplierParty → ruc_emisor, razon_social]
+ E --> F[AccountingCustomerParty → ruc_receptor, razon_social]
+ F --> G[LegalMonetaryTotal → total_factura]
+ G --> H[DespatchDocumentReference → documentos_referencia]
+ H --> I{¿Hay refs?}
+ I -- No --> J[Buscar en descripción de 1ra línea]
+ I -- Sí --> K{Solo si tipo == 01}
+ J --> K
+ K --> L[PaymentTerms → detracción]
+ L --> M[InvoiceLine → líneas de detalle]
+ M --> N[return DatosComprobante]
+
+ classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
+```
+
+**Documentos de referencia** se obtienen de dos formas:
+
+| Origen | Cómo |
+|--------|------|
+| `DespatchDocumentReference` | Tags XML estándar SUNAT (`cbc:ID`, `cbc:DocumentTypeCode`) |
+| Fallback en descripción | Si no hay refs, busca en `cbc:Description` de la primera línea con regex `{serie}-{numero}` |
+
+**Detracción** se extrae de `PaymentTerms` con `ID = "Detraccion"`:
+
+| Campo | Tag |
+|-------|-----|
+| `codigo_detraccion` | `cbc:PaymentMeansID` |
+| `porcentaje_detraccion` | `cbc:PaymentPercent` |
+| `monto_detraccion` | `cbc:Amount` |
+
+**Líneas de detalle** (`InvoiceLine`):
+
+| Campo | Tag |
+|-------|-----|
+| `descripcion` | `cac:Item/cbc:Description` |
+| `cantidad` | `cbc:InvoicedQuantity` |
+| `precio_unitario` | `cac:PricingReference/cac:AlternativeConditionPrice/cbc:PriceAmount` o `cac:Price/cbc:PriceAmount` |
+| `total_linea` | `cbc:LineExtensionAmount` |
+
+---
+
+### `_parse_despatch_advice(root)` — Guía tipo 31
+
+| Campo | Tag |
+|-------|-----|
+| `ruc_emisor` | `DespatchSupplierParty/Party/PartyIdentification/ID` |
+| `razon_social_emisor` | `PartyName/Name` o `PartyLegalEntity/RegistrationName` |
+| `ruc_receptor` | `DeliveryCustomerParty/Party/PartyIdentification/ID` |
+| `razon_social_receptor` | `PartyLegalEntity/RegistrationName` |
+| `punto_partida` | `Delivery/Despatch/DespatchAddress/AddressLine/Line` |
+| `punto_llegada` | `Delivery/DeliveryAddress/AddressLine/Line` |
+| `peso_bruto` | `GrossWeightMeasure` |
+| `unidad_peso_bruto` | `GrossWeightMeasure` (atributo `unitCode`) |
+| `fecha_traslado` | `ShipmentStage/TransitPeriod/StartDate` |
+| `vehiculo_principal` | `TransportHandlingUnit/TransportEquipment/ID` |
+| `vehiculo_secundario` | `TransportHandlingUnit/TransportEquipment/AttachedTransportEquipment/ID` |
+| `licencia_conductor` | `ShipmentStage/DriverPerson/IdentityDocumentReference/ID` |
+| `nombre_conductor` | `ShipmentStage/DriverPerson/FirstName` |
+| `documento_conductor` | `ShipmentStage/DriverPerson/ID` |
+| `ubigeo_partida` | `Delivery/Despatch/DespatchAddress/ID` |
+| `lineas[]` | `DespatchLine` con `DeliveredQuantity` y `Item/Description` |
+
+Documentos de referencia desde `AdditionalDocumentReference`.
+
+---
+
+## Cruce de referencias `:highlight`
+
+### 1. Completar `documentos_referencia` desde PDF
+
+Si el XML no trae documentos de referencia, se buscan en el PDF cache:
+
+```
+pdf_cache[nombre_canonico].items[] → extraer_referencias()
+ └── Por cada item:
+ ├── guias_remision[] → ref tipo 09
+ └── guia_transportista → ref tipo 31
+```
+
+### 2. Asignar guías por línea
+
+Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea:
+
+| Prioridad | Origen | Descripción |
+|-----------|--------|-------------|
+| 1ª | PDF items | Parse desde `items[i].guias_remision[]` y `items[i].guia_transportista` con regex `{T/V/E}{serie}-{numero}` |
+| 2ª | `documentos_referencia` | Asignación ordinal: `refs_09[i]`, `refs_31[i]` |
+| 3ª | Default | `[]` y `None` |
+
+---
+
+## Datos que produce
+
+```python
+# documentos → list[dict]
+[
+ {
+ "ruc_emisor": "20537929520",
+ "razon_social_emisor": "TRANSPORTES S.A.C.",
+ "fecha_emision": "2026-07-08",
+ "tipo": "01",
+ "serie": "F002",
+ "numero": "486",
+ "ruc_receptor": "20123456789",
+ "razon_social_receptor": "CLIENTE S.A.",
+ "total_factura": 4680.0,
+ "tiene_detraccion": True,
+ "codigo_detraccion": "014",
+ "porcentaje_detraccion": 4.0,
+ "monto_detraccion": 187.20,
+ "documentos_referencia": [
+ {"tipo": "09", "serie": "T001", "numero": "127773"}
+ ],
+ "lineas": [
+ {
+ "descripcion": "FLETE T001-127773",
+ "cantidad": 350.0,
+ "precio_unitario": 1.3396,
+ "total_linea": 468.86,
+ "guias_remision": [{"tipo": "09", "serie": "T001", "numero": "127773"}],
+ "guia_transportista": None
+ }
+ ],
+ "ruta_xml": "/path/to/20537929520-01-F002-486.xml",
+ "ruta_pdf": "/path/to/20537929520-01-F002-486.pdf",
+ "tipo_descripcion": "Factura"
+ }
+]
+```
diff --git a/hana_manager.py b/hana_manager.py
new file mode 100644
index 0000000..a855630
--- /dev/null
+++ b/hana_manager.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+import os
+import sys
+import json
+from datetime import datetime, date
+from hdbcli import dbapi
+from dotenv import load_dotenv
+
+# ============================================================================
+# CONFIGURACIÓN
+# ============================================================================
+
+# Cargar variables de entorno desde .env
+load_dotenv(dotenv_path=".env")
+
+# Configuración de la API
+HANA_HOST = os.getenv("HANA_SERVER", "")
+HANA_PORT = int(os.getenv("HANA_PORT", 30015))
+HANA_USER = os.getenv("HANA_USER", "")
+HANA_PASSWORD = os.getenv("HANA_PASSWORD", "")
+HANA_SCHEMA = os.getenv("HANA_SCHEMA", "")
+
+# ============================================================================
+def get_connection():
+ """Crea y retorna una conexión a SAP HANA."""
+ try:
+ conn = dbapi.connect(
+ address=HANA_HOST,
+ port=HANA_PORT,
+ user=HANA_USER,
+ password=HANA_PASSWORD
+ )
+ return conn
+ except Exception as e:
+ print(f" ❌ Error conectando a HANA: {e}")
+ return None
+
+# ============================================================================
+def get_delivery(tipo: str, serie: str, numero: str) -> list:
+ """
+ Consulta la tabla ODLN en SAP HANA para obtener DeliveryNotes.
+
+ Args:
+ tipo: Código de serie/documento (U_BPP_MDSD).
+ serie: Serie del documento (U_BPP_MDTD).
+ numero: Número del documento (se busca con LIKE).
+
+ Returns:
+ list[dict] — Lista con DocEntry, DocNum, CardCode, CardName.
+ """
+ schema = HANA_SCHEMA
+ query = f'''SELECT "DocEntry", "DocNum", "CardCode", "CardName"
+ FROM "{schema}"."ODLN"
+ WHERE "CANCELED" = 'N'
+ AND "U_BPP_MDSD" = ?
+ AND "U_BPP_MDTD" = ?
+ AND "U_BPP_MDCD" LIKE ?'''
+
+ pattern = f"%{numero}%"
+
+ connection = get_connection()
+ if not connection:
+ return []
+
+ cursor = connection.cursor()
+ try:
+ cursor.execute(query, (tipo, serie, pattern))
+ columns = [column[0] for column in cursor.description]
+ results = []
+ for row in cursor.fetchall():
+ results.append(dict(zip(columns, row)))
+ return results
+ except Exception as e:
+ print(f" ❌ Error en consulta HANA get_delivery: {e}")
+ return []
+ finally:
+ if cursor:
+ cursor.close()
+ if connection:
+ connection.close()
+
+# ============================================================================
+def obtener_valor_referencial(serie: str, numero: str) -> float:
+ """
+ Ejecuta el stored procedure Sp_ObtenerValorReferencial en SAP HANA para
+ obtener el valor referencial de una guía de remisión (tipo 09).
+
+ Args:
+ serie: Serie de la guía de remisión (ej. "T001").
+ numero: Número de la guía de remisión (ej. "132800").
+
+ Returns:
+ float — Columna ``ValorReferencial`` del primer registro devuelto
+ por el SP, o 0.0 si no hay resultados o si ocurre un error.
+ """
+ schema = HANA_SCHEMA
+ query = f'CALL "{schema}"."Sp_ObtenerValorReferencial"(?, ?)'
+
+ connection = get_connection()
+ if not connection:
+ return 0.0
+
+ cursor = connection.cursor()
+ try:
+ cursor.execute(query, (serie, numero))
+ columns = [column[0] for column in cursor.description]
+ row = cursor.fetchone()
+ if not row:
+ return 0.0
+ valor = dict(zip(columns, row)).get("ValorReferencial")
+ return float(valor) if valor is not None else 0.0
+ except Exception as e:
+ print(f" ❌ Error en consulta HANA obtener_valor_referencial: {e}")
+ return 0.0
+ finally:
+ if cursor:
+ cursor.close()
+ if connection:
+ connection.close()
+
+# ============================================================================
diff --git a/main.py b/main.py
index b7fd931..e80eced 100644
--- a/main.py
+++ b/main.py
@@ -14,6 +14,7 @@ HORARIOS = [
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 17:30 -> cada 5 minutos
(18, 30, 21, 30, 3600, "full"), # 17:30 - 21:30 -> cada 60 minutos
+ (0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 5 minutos
]
# ============================================================================
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
@@ -64,7 +65,7 @@ def main() -> None:
dias = args.dias
- load_dotenv(dotenv_path=".env.dev")
+ load_dotenv(dotenv_path=".env")
EMAIL = os.getenv("EMAIL", "")
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER", "")
@@ -73,48 +74,52 @@ def main() -> None:
print("🚀 Demonio iniciado")
print("=" * 60)
- while True:
+ try:
+ while True:
- inicio_ciclo = datetime.now()
+ inicio_ciclo = datetime.now()
- resultado = obtener_intervalo(inicio_ciclo)
+ resultado = obtener_intervalo(inicio_ciclo)
- if resultado is not None:
+ if resultado is not None:
- intervalo, modo = resultado
+ intervalo, modo = resultado
- print(
- f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
- f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
- )
-
- run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
-
- duracion = (datetime.now() - inicio_ciclo).total_seconds()
-
- if duracion > intervalo:
print(
- f"⚠️ El ciclo demoró {duracion:.1f}s "
- f"(intervalo configurado {intervalo}s)"
+ f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
+ f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
)
- dormir = max(0, intervalo - duracion)
+ run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
- print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
+ duracion = (datetime.now() - inicio_ciclo).total_seconds()
- else:
+ if duracion > intervalo:
+ print(
+ f"⚠️ El ciclo demoró {duracion:.1f}s "
+ f"(intervalo configurado {intervalo}s)"
+ )
- dormir = segundos_hasta_proximo_inicio(inicio_ciclo)
+ dormir = max(0, intervalo - duracion)
- despertar = inicio_ciclo + timedelta(seconds=dormir)
+ print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
- print(
- f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
- f"Fuera del horario.\n"
- f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
- )
+ else:
- time.sleep(dormir)
+ dormir = segundos_hasta_proximo_inicio(inicio_ciclo)
+
+ despertar = inicio_ciclo + timedelta(seconds=dormir)
+
+ print(
+ f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
+ f"Fuera del horario.\n"
+ f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
+ )
+
+ time.sleep(dormir)
+
+ except KeyboardInterrupt:
+ print("\n\n🛑 Demonio detenido por el usuario.")
# ============================================================================
diff --git a/outlook_utilities.py b/outlook_utilities.py
index 08b8c3e..32e7104 100644
--- a/outlook_utilities.py
+++ b/outlook_utilities.py
@@ -12,7 +12,7 @@ warnings.filterwarnings("ignore")
# ============================================================================
# Cargar variables de entorno desde .env
-load_dotenv(dotenv_path=".env.dev")
+load_dotenv(dotenv_path=".env")
# Configuración de la API
CLIENT_ID = os.getenv("CLIENT_ID", "")
@@ -190,10 +190,16 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
encoded_content = base64.b64encode(content_bytes).decode('utf-8')
filename = os.path.basename(path)
+ ext = os.path.splitext(filename)[1].lower()
+ content_type = {
+ ".pdf": "application/pdf",
+ ".xml": "application/xml",
+ ".json": "application/json",
+ }.get(ext, "application/octet-stream")
attachments_payload.append({
"@odata.type": "#microsoft.graph.fileAttachment",
"name": filename,
- "contentType": "application/pdf",
+ "contentType": content_type,
"contentBytes": encoded_content
})
else:
diff --git a/pdf_utilities.py b/pdf_utilities.py
index 808b23d..3b78357 100644
--- a/pdf_utilities.py
+++ b/pdf_utilities.py
@@ -134,55 +134,69 @@ def _extract_factura_details(text: str, data: dict) -> None:
if table_start_index != -1:
items_lines = lines[table_start_index + 1:]
current_item = None
+ pre_item_buffer = []
for line in items_lines:
line = line.strip()
if not line: continue
if re.search(r"^(?:SUB|TOTAL|IGV|SON:|OP\.|RES|CUO|DET|BANCO|Representación|Podrá|Autorizado|FORMA|CUOTA|GRAVADO|I\.G\.V\.|DESEMBOLSO|OBSERVACION)", line, re.IGNORECASE):
break
- amounts = re.findall(r"(?= 2:
if current_item: data["items"].append(current_item)
qty = amounts[0]
- total = amounts[-1]
- p_unit = amounts[-2]
- if len(amounts) >= 4:
- p_unit = amounts[1]
+ p_unit = amounts[-2].replace(",", "")
+ total = amounts[-1].replace(",", "")
desc_line = line
- for amt in [qty, p_unit, total]:
- desc_line = desc_line.replace(amt, "", 1)
+ seen = set()
+ for amt in amounts:
+ if amt not in seen:
+ desc_line = desc_line.replace(amt, "", 1)
+ seen.add(amt)
desc_parts = [p.strip() for p in desc_line.split() if p.strip()]
- codigo = None
- for i in range(min(3, len(desc_parts))):
- if re.search(r"[A-Z].*\d|\d.*[A-Z]|[A-Z]{2,}-\d+", desc_parts[i]):
- codigo = desc_parts[i]
- desc_parts.pop(i)
+ cod = None
+ for j in range(min(3, len(desc_parts))):
+ if re.search(r"[A-Z].*\d|\d.*[A-Z]|[A-Z]{2,}-\d+", desc_parts[j]):
+ cod = desc_parts[j]
+ desc_parts.pop(j)
break
if desc_parts and desc_parts[0].isdigit() and len(desc_parts[0]) <= 2:
desc_parts.pop(0)
descripcion = " ".join(desc_parts)
current_item = {
- "codigo": codigo,
+ "codigo": cod,
"cantidad": qty,
"descripcion": descripcion,
- "precio_unitario": p_unit.replace(",", ""),
- "total": total.replace(",", ""),
+ "precio_unitario": p_unit,
+ "total": total,
"guias_remision": [],
"guia_transportista": None,
"bolsas": None,
"peso": None,
"unidad_peso": None,
}
+ if pre_item_buffer:
+ current_item["descripcion"] = " ".join(pre_item_buffer) + " " + descripcion
+ pre_item_buffer = []
elif current_item and len(line) > 5:
current_item["descripcion"] += " " + line
+ elif not current_item and len(line) > 5:
+ pre_item_buffer.append(line)
if current_item: data["items"].append(current_item)
+ # Extraer guías de cada ítem usando los mismos patrones que sunat_schema
+ GUIAS_PDF_PATTERNS = [
+ (r"(?:GRE\s+REMITENTE|GRR|G\.R\.R)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "09"),
+ (r"\b(?:GRT|G\.R\.T)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "31"),
+ ]
for item in data["items"]:
desc = item["descripcion"]
- guias_remision_matches = re.findall(r"\b(?:GRR\w*|GR\b|G\.R\.R\.?|GUIA REMISI.N)\s*[:/\s]*((?:[TVE][A-Z0-9]{3})-\d+)", desc, re.IGNORECASE)
- item["guias_remision"] = [m.strip() for m in guias_remision_matches] if guias_remision_matches else []
- guia_transportista_match = re.search(r"\b(?:GRT\w*|GR\b|G\.R\.T\.?|GUIA TRANSPORTISTA)\s*[:/\s]*((?:[TVE][A-Z0-9]{3})-\d+)", desc, re.IGNORECASE)
- if guia_transportista_match:
- item["guia_transportista"] = guia_transportista_match.group(1).strip()
+ for patron, tipo in GUIAS_PDF_PATTERNS:
+ for m in re.finditer(patron, desc, re.IGNORECASE):
+ numero = m.group(2).lstrip("0") or "0"
+ if tipo == "09":
+ item["guias_remision"].append(f"{m.group(1).upper()}-{numero}")
+ else:
+ item["guia_transportista"] = f"{m.group(1).upper()}-{numero}"
bolsas_match = re.search(r"(\d+)\s*(?:BOLSAS|BLS|BOLS|SACOS)\b", desc, re.IGNORECASE)
if bolsas_match:
item["bolsas"] = bolsas_match.group(1)
diff --git a/pipeline.py b/pipeline.py
index bf3a7a7..da81797 100644
--- a/pipeline.py
+++ b/pipeline.py
@@ -20,14 +20,7 @@ import database_utilities
import sunat_schema
import sapb1_manager
import pdf_utilities
-
-CUADRO_VALOR_REFERENCIAL = {
- "CHINCHA": 89.09,
- "PISCO": 101.7,
- "CAÑETE": 79.81,
- "TARMA": 106.54,
- "SAN CLEMENTE": 94.27,
-}
+import hana_manager
# ============================================================================
@@ -104,6 +97,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
notificaciones por email.
"""
+ def _log(msg: str, level: int = 1, last: bool = False) -> None:
+ branch = " └── " if last else " ├── "
+ pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
+ print(f"{pfx}{msg}")
+
def _descargar_adjuntos() -> list[str] | None:
"""
Descarga todos los adjuntos del correo usando Microsoft Graph.
@@ -130,7 +128,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
return adjuntos
return None
except Exception as ex:
- print(f" ❌ Error descargando adjuntos: {ex}")
+ _log(f"❌ Error descargando adjuntos: {ex}")
return None
def _descomprimir() -> None:
@@ -179,7 +177,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
ruta = os.path.join(carpeta, a)
if os.path.exists(ruta):
os.remove(ruta)
- print(f" ├── 🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
+ _log("🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
return False
for a in no_validos:
@@ -236,9 +234,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
canonical = os.path.basename(nuevo_path)
pdf_cache[canonical] = data
- print(f" │ 📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})")
+ _log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
else:
- print(f" │ ⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}")
+ _log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2)
if datos_pdf:
email["datos_pdf"] = datos_pdf
@@ -256,7 +254,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
3. Si el XML no trae ``documentos_referencia``, los completa desde
el PDF cache (usando ``pdf_utilities.extraer_referencias``).
4. Asigna las guías de remisión (``guias_remision[]``) y
- transportista (``guia_transportista``) a cada línea de detalle.
+ transportista (``guia_transportista``) a cada línea de detalle desde
+ ``notes_text`` o ``documentos_referencia``.
Returns:
list[dict] — Lista de documentos extraídos listos para
@@ -277,26 +276,49 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
carpeta = _ruta_carpeta(download_folder)
documentos = []
+ last_idx = len(email["adjuntos"]) - 1
for idx, a in enumerate(email["adjuntos"]):
- prefijo = " │ └── " if idx == len(email["adjuntos"]) - 1 else " │ ├── "
- print(f"{prefijo}{a}")
+ _log(a, 2, last=(idx == last_idx))
if not a.lower().endswith(".xml"):
continue
xml_path = os.path.join(carpeta, a)
- print(f" │ 📄 Procesando XML: {a}")
+ _log(f"📄 Procesando XML: {a}", 3)
data = sunat_schema.extraer_datos_xml(xml_path)
if not data:
- print(f" │ ℹ️ XML sin datos (no es factura tipo 01 o guía 31)")
+ _log("ℹ️ XML sin datos (no es factura tipo 01 o guía 31)", 3)
+ continue
+
+ # CDR solo se renombra, no se procesa como documento
+ if data.get("es_cdr"):
+ prefijo = "CDR-"
+ cdr_name = f"{prefijo}{data['ruc_emisor']}-{data['tipo']}-{data['serie']}-{data['numero']}.xml"
+ cdr_dest = os.path.join(carpeta, cdr_name)
+ if cdr_dest != xml_path:
+ if os.path.exists(cdr_dest):
+ os.remove(cdr_dest)
+ os.rename(xml_path, cdr_dest)
+ email["adjuntos"][idx] = cdr_name
+ _log(f"ℹ️ {data.get('serie','?')}-{data.get('numero','?')} (CDR) — solo renombrado", 3)
continue
data["ruta_xml"] = xml_path
+ # Renombrar XML a {prefijo}{ruc}-{tipo}-{serie}-{numero}.xml
+ prefijo = ""
+ xml_canonical = f"{prefijo}{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.xml"
+ xml_destino = os.path.join(carpeta, xml_canonical)
+ if xml_destino != xml_path:
+ if os.path.exists(xml_destino):
+ os.remove(xml_destino)
+ os.rename(xml_path, xml_destino)
+ email["adjuntos"][idx] = xml_canonical
+ data["ruta_xml"] = xml_destino
+ xml_path = xml_destino
+
# ruta_pdf: construir nombre canónico {ruc}-{tipo}-{serie}-{numero}.pdf
- # El número ya viene con lstrip("0") desde _split_serie_numero en sunat_schema
- # y generar_nombre_pdf ahora también aplica lstrip("0") para que coincidan
pdf_canonical = f"{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.pdf"
pdf_path = os.path.join(carpeta, pdf_canonical)
if not os.path.exists(pdf_path):
@@ -312,93 +334,257 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
if refs:
data["documentos_referencia"] = refs
- print(f" │ 🔄 documentos_referencia completados desde PDF ({len(refs)} refs)")
+ _log(f"🔄 documentos_referencia completados desde PDF ({len(refs)} refs)", 3)
if not data.get("documentos_referencia"):
for pdf_data in email.get("datos_pdf", []):
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
if refs:
data["documentos_referencia"] = refs
- print(f" │ 🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)")
+ _log(f"🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)", 3)
break
- # Asignar guias_remision[] y guia_transportista a cada línea
- # Primero: todas las refs tipo 09 y 31 desde documentos_referencia
+ # Asignar guias_remision[] y guia_transportista por línea
+ # Prioridad: notes_text (ya parseado) > documentos_referencia
refs_09 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "09"]
refs_31 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "31"]
- # Segundo: si hay datos del PDF, usar agrupación por ítem (respeta multi-guía)
- pdf_items = (pdf_cache.get(pdf_canonical) or {}).get("items", [])
- for i, linea in enumerate(data.get("lineas", [])):
- if i < len(pdf_items):
- pi = pdf_items[i]
- linea["guias_remision"] = []
- for g_str in pi.get("guias_remision", []):
- m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", g_str)
- if m:
- linea["guias_remision"].append({
- "tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2),
- })
- else:
- partes = g_str.split("-", 1)
- if len(partes) == 2:
- linea["guias_remision"].append({
- "tipo": "09", "serie": partes[0].strip(), "numero": partes[1].strip(),
- })
- guia_transportista = pi.get("guia_transportista")
- if guia_transportista:
- m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", guia_transportista)
- if m:
- pi["guia_transportista_serie"] = m.group(1)
- pi["guia_transportista_numero"] = m.group(2)
- elif "-" in guia_transportista:
- partes_list = guia_transportista.split("-", 1)
- linea["guia_transportista"] = {"tipo": "31", "serie": partes_list[0].strip(), "numero": partes_list[1].strip()}
- else:
- linea["guia_transportista"] = None
- else:
- linea["guia_transportista"] = None
- elif i < len(refs_09):
- r = refs_09[i]
- linea["guias_remision"] = [{"tipo": "09", "serie": r["serie"].strip(), "numero": r["numero"].strip()}]
- linea["guia_transportista"] = refs_31[i] if i < len(refs_31) else None
- else:
- linea["guias_remision"] = []
- linea["guia_transportista"] = None
- print(f" │ ✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})")
+ for i, linea in enumerate(data.get("lineas", [])):
+ gr = linea.get("guias_remision") or []
+ gt = linea.get("guia_transportista")
+
+ if not gr and i < len(refs_09):
+ es_ultima = i == len(data["lineas"]) - 1
+ if es_ultima:
+ gr = list(refs_09[i:])
+ else:
+ gr = [dict(refs_09[i])]
+ if not gt and i < len(refs_31):
+ gt = dict(refs_31[i])
+
+ linea["guias_remision"] = gr
+ if gt:
+ linea["guia_transportista"] = gt
+
+ _log(f"✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})", 3)
documentos.append(data)
+ # Post-procesamiento: resolver GREs desde guías de transporte referenciadas
+ guia_lookup = {}
+ for doc in documentos:
+ if doc.get("tipo") in ("31", "09"):
+ guia_lookup[(doc["tipo"], doc["serie"], doc["numero"])] = doc
+
+ for doc in documentos:
+ if doc.get("tipo") != "01":
+ continue
+ for ref in doc.get("documentos_referencia", []):
+ if ref["tipo"] == "31":
+ guia_doc = guia_lookup.get(("31", ref["serie"], ref["numero"]))
+ if guia_doc:
+ gre_list = [g for g in guia_doc.get("documentos_referencia", []) if g.get("tipo") == "09"]
+ if gre_list:
+ _log(f"🔗 Resueltas {len(gre_list)} GRE(s) desde guía {ref['serie']}-{ref['numero']}", 3)
+ for i, linea in enumerate(doc.get("lineas", [])):
+ if not linea.get("guias_remision"):
+ es_ultima = i == len(doc["lineas"]) - 1
+ if es_ultima:
+ linea["guias_remision"] = gre_list[i:]
+ elif i < len(gre_list):
+ linea["guias_remision"] = [gre_list[i]]
+ if not linea.get("guia_transportista"):
+ linea["guia_transportista"] = dict(ref)
+
return documentos
- def _guardar_resultado(documentos: list[dict]) -> None:
+ def _completar_guias(documentos: list[dict], pdf_cache: dict) -> int:
"""
- Guarda la lista de documentos extraídos en un archivo JSON.
+ Completa guías faltantes en XML desde datos del PDF.
+
+ Para cada documento en ``documentos``, verifica si todas las líneas
+ tienen ``guias_remision``. Si faltan, busca el PDF correspondiente en
+ el cache y copia las guías de las líneas del PDF.
+
+ Args:
+ documentos: Lista de documentos extraídos de XML.
+ pdf_cache: Cache de datos PDF (canonical_name → datos).
+
+ Returns:
+ int — Número de guías copiadas.
+ """
+ total = 0
+ for xd in documentos:
+ has_all = all(
+ len(linea.get("guias_remision") or []) > 0
+ for linea in xd.get("lineas", [])
+ )
+ if has_all:
+ continue
+
+ pdf_canonical = f"{xd.get('ruc_emisor', '')}-{xd.get('tipo', '')}-{xd.get('serie', '')}-{xd.get('numero', '')}.pdf"
+ pd = pdf_cache.get(pdf_canonical)
+ if not pd:
+ continue
+
+ for i, xl in enumerate(xd.get("lineas", [])):
+ if xl.get("guias_remision"):
+ continue
+ pl = pd["items"][i] if i < len(pd.get("items", [])) else {}
+ p_gr = pl.get("guias_remision") or []
+ if isinstance(p_gr[0], str) if p_gr else False:
+ # Normalizar formato string → dict
+ p_gr = [
+ {"tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2).lstrip("0") or "0"}
+ for g in p_gr
+ for m in [re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)]
+ if m
+ ]
+ if p_gr:
+ xl["guias_remision"] = list(p_gr)
+ total += len(p_gr)
+ if not xl.get("guia_transportista") and pl.get("guia_transportista"):
+ gt = pl["guia_transportista"]
+ if isinstance(gt, str):
+ m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
+ if m:
+ xl["guia_transportista"] = {
+ "tipo": "31",
+ "serie": m.group(1).upper(),
+ "numero": m.group(2).lstrip("0") or "0",
+ }
+ elif isinstance(gt, dict):
+ xl["guia_transportista"] = dict(gt)
+ return total
+
+ def _validar_guias(documentos: list[dict]) -> None:
+ for doc in documentos:
+ for i, linea in enumerate(doc.get("lineas", [])):
+ for j, g in enumerate(linea.get("guias_remision") or []):
+ if not isinstance(g, dict) or not all(k in g and g[k] for k in ("tipo", "serie", "numero")):
+ _log(f"⚠️ guias_remision[{j}] inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {g}", 2)
+ gt = linea.get("guia_transportista")
+ if gt and (not isinstance(gt, dict) or not all(k in gt and gt[k] for k in ("tipo", "serie", "numero"))):
+ _log(f"⚠️ guia_transportista inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {gt}", 2)
+
+ def _guardar_resultado(documentos: list[dict], datos_pdf: list[dict]) -> list[str]:
+ """
+ Guarda los resultados XML y PDF en archivos JSON separados.
El nombre del archivo sigue el patrón
- ``resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
+ ``{tipo}_resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
``output_folder``.
Args:
documentos: Lista de diccionarios con datos extraídos de XML
(producida por ``_procesar_xmls``).
+ datos_pdf: Lista de diccionarios con datos extraídos de PDF
+ (producida por ``_procesar_pdfs``).
Example:
Si ``documentos[0]`` tiene ``serie="F002", numero="486"``::
- _guardar_resultado(documentos)
- # → Crea resultado_F002-486_2026-07-08_12-00-00.json
+ _guardar_resultado(documentos, pdf_datos)
+ # → Crea xml_resultado_F002-486_2026-07-08_12-00-00.json
+ # → Crea pdf_resultado_F002-486_2026-07-08_12-00-00.json
"""
if not documentos:
- return
+ return []
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
serie = documentos[0].get("serie", "unknown")
numero = documentos[0].get("numero", "unknown")
- filename = f"resultado_{serie}-{numero}_{ts}.json"
- filepath = os.path.join(output_folder, filename)
- with open(filepath, "w", encoding="utf-8") as f:
+ base = f"{serie}-{numero}_{ts}"
+
+ saved: list[str] = []
+
+ # XML result
+ xml_name = f"xml_resultado_{base}.json"
+ xml_path = os.path.join(output_folder, xml_name)
+ with open(xml_path, "w", encoding="utf-8") as f:
json.dump(documentos, f, ensure_ascii=False, indent=4)
- print(f" 💾 Resultado guardado: {filename}")
+ _log(f"💾 XML guardado: {xml_name}")
+ saved.append(xml_path)
+
+ # PDF result (normalizado al mismo schema que XML)
+ pdf_docs = []
+ for raw in datos_pdf:
+ if raw.get("error"):
+ continue
+ td = (raw.get("tipo_documento") or "").upper()
+ tipo = pdf_utilities.TIPO_MAP.get(td)
+ if not tipo:
+ continue
+ sn = raw.get("serie_numero") or ""
+ serie_p, num_p = sunat_schema.split_serie_numero(sn)
+ if not serie_p:
+ continue
+ doc = {
+ "ruc_emisor": raw.get("ruc_emisor"),
+ "razon_social_emisor": raw.get("nombre_receptor"),
+ "fecha_emision": raw.get("fecha_emision"),
+ "tipo": tipo,
+ "serie": serie_p,
+ "numero": num_p,
+ "ruc_receptor": raw.get("ruc_receptor"),
+ "razon_social_receptor": raw.get("nombre_receptor"),
+ "total_factura": raw.get("monto_total"),
+ "documentos_referencia": [],
+ "tiene_detraccion": False,
+ "codigo_detraccion": None,
+ "porcentaje_detraccion": None,
+ "monto_detraccion": None,
+ "lineas": [],
+ "ruta_pdf": raw.get("archivo"),
+ }
+ det = raw.get("detraccion") or {}
+ if det.get("tiene"):
+ doc["tiene_detraccion"] = True
+ doc["porcentaje_detraccion"] = det.get("porcentaje")
+ doc["monto_detraccion"] = det.get("monto")
+ for item in raw.get("items") or []:
+ linea = {
+ "descripcion": item.get("descripcion", ""),
+ "cantidad": item.get("cantidad"),
+ "precio_unitario": item.get("precio_unitario"),
+ "total_linea": item.get("total"),
+ "guias_remision": [],
+ "guia_transportista": None,
+ }
+ for g in item.get("guias_remision") or []:
+ if isinstance(g, str):
+ m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)
+ if m:
+ linea["guias_remision"].append({
+ "tipo": "09",
+ "serie": m.group(1).upper(),
+ "numero": m.group(2).lstrip("0") or "0",
+ })
+ elif isinstance(g, dict):
+ linea["guias_remision"].append(dict(g))
+ gt = item.get("guia_transportista")
+ if gt:
+ if isinstance(gt, str):
+ m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
+ if m:
+ linea["guia_transportista"] = {
+ "tipo": "31",
+ "serie": m.group(1).upper(),
+ "numero": m.group(2).lstrip("0") or "0",
+ }
+ elif isinstance(gt, dict):
+ linea["guia_transportista"] = dict(gt)
+ doc["lineas"].append(linea)
+ pdf_docs.append(doc)
+
+ if pdf_docs:
+ pdf_name = f"pdf_resultado_{base}.json"
+ pdf_path = os.path.join(output_folder, pdf_name)
+ with open(pdf_path, "w", encoding="utf-8") as f:
+ json.dump(pdf_docs, f, ensure_ascii=False, indent=4)
+ _log(f"💾 PDF guardado: {pdf_name}")
+ saved.append(pdf_path)
+
+ return saved
def _build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
"""
@@ -420,12 +606,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
- ``WarehouseCode``, ``U_IDC_GRFLETE`` y campos fijos.
Cuando la validación es ``ok=True`` usa los valores de SAP
(``draft_quantity``, ``draft_price``); cuando no, usa los del XML.
- 5. Calcula ``U_IDC_VALREF`` como:
- ``total_weight × CUADRO_VALOR_REFERENCIAL[zona]``
- donde ``total_weight`` es la suma de ``Weight1`` de todos los
- DeliveryNotes de la guía, y ``zona`` se obtiene del campo ``U_IDC_DESTINO`` del primer
- DeliveryNote de la primera guía, mapeado contra el maestro de
- zonas SAP (``U_IDC_ZONAS``).
+ 5. Calcula ``U_IDC_VALREF`` como la suma de ``valor_referencial``
+ (extraído del XML SUNAT por ``sunat_schema``) de todas las
+ líneas de la factura.
Args:
factura: Diccionario con los datos de la factura extraídos del
@@ -475,11 +658,14 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
emision = datetime.strptime(fecha_emision, "%Y-%m-%d")
item_code = "C103002-A" if (emision.year, emision.month) < (datetime.now().year, datetime.now().month) else "C103002"
- neto = round(total - monto_detraccion, 2)
+
if tiene_detraccion:
+ # neto = round(total - monto_detraccion, 2)
+ monto_detraccion = math.ceil(monto_detraccion)
+ neto = total - monto_detraccion
installments = [
- {"DueDate": fecha_emision, "Total": round(monto_detraccion, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
- {"DueDate": fecha_emision, "Total": neto, "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
+ {"DueDate": fecha_emision, "Total": monto_detraccion, "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
+ {"DueDate": fecha_emision, "Total": round(neto,2), "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
]
else:
installments = [
@@ -487,8 +673,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
]
doc_lines = []
- total_weight = 0
- zona_guia = ""
guia_referencia = ""
for i, linea in enumerate(factura.get("lineas", [])):
@@ -497,18 +681,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
if not guias_lt:
continue
-
- zona_guia = guias_lt[0].get("zona","")
-
guia_referencia = f"{guias_lt[0]['guia']['tipo']}{guias_lt[0]['guia']['serie']}-{guias_lt[0]['guia']['numero']}"
-
+
for item in guias_lt:
codigo_guia = f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}"
lt_val = math.trunc(item["draft_price"] * 1_000_000) / 1_000_000
qty = item.get("draft_quantity", int(item.get("quantity", 1)))
- total_weight += item.get("weight1", 0)
-
doc_lines.append({
"ItemCode": item_code,
#"ItemDescription": linea.get("descripcion", "TRANSPORTE DE CARGA MERCADERIA")[:200],
@@ -529,7 +708,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
})
- valor_referencial = round((total_weight / 1000 )* (CUADRO_VALOR_REFERENCIAL.get(zona_guia, 0)),2)
+ valor_referencial = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
return {
"DocType": "dDocument_Items",
@@ -544,6 +723,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
"JournalMemo": razon_social_emisor[:254],
"DocObjectCode": "oPurchaseInvoices",
"U_BPV_SERI": f"{serie}-01",
+ "U_VS_NATNUM": "T",
"U_BPV_NCON2": numero,
"U_BPP_MDTD": tipo,
"U_BPP_MDBI": "A",
@@ -649,14 +829,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
for guia in guias:
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
try:
- # consulta a sap por cada guia de remision
- # esto nos puede retornar 0, 1 o mas documentos
- # lo ideal es que nos retorne 0 o 1 documento
delivery_notes = sapb1_manager.get_delivery_notes(
tipo=guia["serie"], serie=guia["tipo"], numero=guia["numero"]
)
- # cantidad de documentos encontrados en sap
- print(f" │ 🔍 Guía {codigo_guia}: {len(delivery_notes)} documento(s) encontrado(s) en SAP")
wh_set = set()
suma_line_total = 0.0
@@ -665,7 +840,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
weight1 = 0.0
zona = ""
- # recorremos cada documento encontrado
for dn in delivery_notes:
# obtenemos la zona de destino del documento
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
@@ -697,17 +871,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
max_qty = max(max_qty, quantity)
sum_pav += price_after_vat
- print(f" │ └─ {dn.get('Series', '?')}-{dn.get('DocNum', '?')} | ItemCode: {linea_detalle_dict.get('ItemCode', '')} | PriceAfterVAT: {price_after_vat:.6f} | Quantity: {quantity:.6f} | LineTotal: {line_total:.6f}")
+ _log(f"└─ {dn.get('Series', '?')}-{dn.get('DocNum', '?')} | ItemCode: {linea_detalle_dict.get('ItemCode', '')} | PriceAfterVAT: {price_after_vat:.6f} | Quantity: {quantity:.6f} | LineTotal: {line_total:.6f}", 4)
guias_lt.append({"guia": guia, "line_total": suma_line_total, "warehouse_code": ", ".join(sorted(wh_set)) if wh_set else "A-CENTRA", "quantity": max_qty, "price_after_vat_sum": sum_pav, "zona":zona, "weight1": weight1, "draft_quantity": 0, "draft_price": 0.0})
all_wh.update(wh_set)
except Exception as ex:
- print(f" │ ⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}")
+ _log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2)
guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0})
all_ok = False
sap_total = sum(item["line_total"] for item in guias_lt)
+ sin_sap = all(item["line_total"] == 0.0 and item["quantity"] == 0.0 for item in guias_lt)
ok = all_ok and abs(total_linea - sap_total) < 0.01
+ label = "Sin guías SAP" if sin_sap else "LineTotal (FL*) vs total_linea"
grflete_str = "; ".join(f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}" for item in guias_lt)
wh_str = ", ".join(sorted(all_wh)) if all_wh else "A-CENTRA"
@@ -724,16 +900,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
"ok": ok,
"draft_price": total_linea,
"sap_price": sap_total,
- "label": "LineTotal (FL*) vs total_linea",
+ "label": label,
"warehouse_code": wh_str,
"guias_lt": guias_lt,
})
- print(f" │ Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}")
- if ok:
- print(f" │ ✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}")
+ if sin_sap:
+ _log("⏭️ Sin guías en SAP — línea saltada", 3)
else:
- print(f" │ ❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}")
+ _log(f"Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}", 3)
+ if ok:
+ _log(f"✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}", 3)
+ else:
+ _log(f"❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}", 3)
todas_coinciden = all(r["ok"] for r in resultados)
return todas_coinciden, resultados
@@ -768,7 +947,72 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
f"{rows}"
)
- def _build_notification_body(status: str, factura: dict, validation_table: str, **kwargs) -> str:
+ def _obtener_valor_referencial_sap(factura: dict) -> list[dict]:
+ """
+ Ejecuta ``Sp_ObtenerValorReferencial`` en SAP HANA por cada guía de
+ remisión (tipo 09) única de la factura y acumula el resultado.
+
+ Una factura puede referenciar la misma guía desde más de una línea;
+ en ese caso solo se consulta una vez por guía.
+
+ Args:
+ factura: Diccionario de la factura, con ``lineas[]`` y cada
+ línea con ``guias_remision[]`` (tipo 09).
+
+ Returns:
+ list[dict] — uno por guía única, con ``serie``, ``numero`` y
+ ``valor_sap``.
+ """
+ vistos = set()
+ resultados = []
+ for linea in factura.get("lineas", []):
+ for guia in linea.get("guias_remision", []):
+ clave = (guia.get("serie"), guia.get("numero"))
+ if clave in vistos:
+ continue
+ vistos.add(clave)
+ valor_sap = hana_manager.obtener_valor_referencial(guia.get("serie", ""), guia.get("numero", ""))
+ resultados.append({"serie": guia.get("serie", ""), "numero": guia.get("numero", ""), "valor_sap": valor_sap})
+ _log(f"└─ Guía 09{guia.get('serie')}-{guia.get('numero')} | Valor Referencial SAP: {valor_sap:.2f}", 3)
+ return resultados
+
+ def _build_valor_referencial_html(factura: dict, guias_valor_ref: list[dict]) -> str:
+ """
+ Genera una tabla HTML comparando el valor referencial del XML de la
+ factura contra el valor referencial obtenido en SAP
+ (``Sp_ObtenerValorReferencial``) por cada guía de remisión, y el
+ total acumulado.
+
+ Args:
+ factura: Diccionario de la factura (usa ``lineas[]`` para sumar
+ ``valor_referencial`` extraído del XML).
+ guias_valor_ref: Resultado de ``_obtener_valor_referencial_sap``.
+
+ Returns:
+ str — Tabla HTML con una fila por guía, el total SAP, el valor
+ del XML y un ícono de coincidencia.
+ """
+ valor_xml = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
+ total_sap = sum(g["valor_sap"] for g in guias_valor_ref)
+
+ rows = "".join(
+ f"
| Guía de Remisión | Valor Referencial SAP |
|---|---|
| Total SAP | {total_sap:.2f} |
| Valor Referencial XML | {valor_xml:.2f} |
| {icono} {'Coincide' if coincide else 'No coincide'} | |
✅ Draft SAP B1 creado exitosamente.
..." """ @@ -800,12 +1046,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde f"Factura: {factura.get('serie')}-{factura.get('numero')}
" f"Emisor: {factura.get('razon_social_emisor', '')}
" ) + valref_section = f"✅ Draft SAP B1 creado exitosamente.
DocEntry: {kwargs.get('doc_entry')}
{base}✅ Draft SAP B1 creado exitosamente.
DocEntry: {kwargs.get('doc_entry')}
{base}❌ Error al crear Draft SAP B1.
{base}Error: {kwargs.get('error')}
❌ Error al crear Draft SAP B1.
{base}Error: {kwargs.get('error')}
⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.
{base}⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.
{base}