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:
GeorgeandClaude Sonnet 5 committed 2026-09-02 07:40:26 -05:00
1 parent fcc80d3c6e
commit e6cde62e72
14 files changed
+1711 -237

No files matched your search

+95
View File
@@ -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 |
+92
View File
@@ -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()
```
+114
View File
@@ -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
```
-29
View File
@@ -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
```
+262
View File
@@ -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)
```
+257
View File
@@ -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"
}
]
```