se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fcc80d3c6e
commit
e6cde62e72
14 files changed
+1711
-237
No files matched your search
@@ -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 |
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,29 +0,0 @@
|
||||
# Pipeline Completo
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["📬 Correo recibido"] --> B{"1-2. BD check<br>¿Ya procesado?"}
|
||||
B -->|"Sí"| C["⏭️ Saltar correo"]
|
||||
B -->|"No"| D["3. Descargar adjuntos<br>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<br>extraer datos + renombrar"]
|
||||
I --> J["7. Procesar XMLs<br>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<br>_validate_prices()"]
|
||||
O --> P["11. Construir draft SAP<br>_build_sap_draft()"]
|
||||
P --> Q["12. Guardar draft JSON"]
|
||||
Q --> R{"13. ¿Precios coinciden?"}
|
||||
R -->|Sí| S["Crear Draft en SAP B1<br>POST /Drafts"]
|
||||
R -->|No| T["Notificar: validación falló"]
|
||||
S --> U["14. Enviar email notificación"]
|
||||
T --> U
|
||||
N --> U
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
Reference in new issue
Block a user