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
+1672 -198

No files matched your search

+2
View File
@@ -112,3 +112,5 @@ def delete_email_by_id(email_id: str) -> bool:
return rows_affected > 0
# ============================================================================
if __name__ == "__main__":
delete_email_by_id("AAMkADljMzNhYmQwLTQyMjgtNDY2My05ZDUwLWEzNTBiNDZhZjFlMwBGAAAAAAB386nfKhkcTbYV6pPUJ245BwBdNWALkxgeSJ3BdBRukthBAAAAAAEMAABdNWALkxgeSJ3BdBRukthBAAAtOXesAAA=")
+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"
}
]
```
+121
View File
@@ -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()
# ============================================================================
+6 -1
View File
@@ -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,6 +74,7 @@ def main() -> None:
print("🚀 Demonio iniciado")
print("=" * 60)
try:
while True:
inicio_ciclo = datetime.now()
@@ -116,6 +118,9 @@ def main() -> None:
time.sleep(dormir)
except KeyboardInterrupt:
print("\n\n🛑 Demonio detenido por el usuario.")
# ============================================================================
if __name__ == "__main__":
+8 -2
View File
@@ -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:
+33 -19
View File
@@ -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"(?<![A-Z0-9-])(\d+(?:,\d{3})*(?:\.\d{2,}))(?![A-Z0-9-])", line)
amounts = re.findall(r"(?<![A-Z0-9-])(\d+(?:,\d{3})*(?:\.\d{2,}))(?:(?=\s)|(?=$)|(?=[A-Z][a-z]))", line)
if len(amounts) >= 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]:
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)
+392 -127
View File
@@ -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,9 +681,6 @@ 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:
@@ -507,8 +688,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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}</table>"
)
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"<tr><td>09{g['serie']}-{g['numero']}</td><td>{g['valor_sap']:.2f}</td></tr>"
for g in guias_valor_ref
)
coincide = abs(valor_xml - total_sap) < 0.01
icono = "✅" if coincide else "❌"
return (
"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse;'>"
"<tr><th>Guía de Remisión</th><th>Valor Referencial SAP</th></tr>"
f"{rows}"
f"<tr><td><b>Total SAP</b></td><td><b>{total_sap:.2f}</b></td></tr>"
f"<tr><td><b>Valor Referencial XML</b></td><td><b>{valor_xml:.2f}</b></td></tr>"
f"<tr><td colspan='2'>{icono} {'Coincide' if coincide else 'No coincide'}</td></tr>"
"</table>"
)
def _build_notification_body(status: str, factura: dict, validation_table: str, valor_referencial_table: str, **kwargs) -> str:
"""
Construye el cuerpo HTML del email de notificación del resultado
del proceso SAP.
@@ -781,6 +1025,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
``numero``, ``razon_social_emisor``).
validation_table: Tabla HTML generada por
``_build_validation_html``.
valor_referencial_table: Tabla HTML generada por
``_build_valor_referencial_html``.
**kwargs: Argumentos adicionales:
- ``doc_entry`` (int): DocEntry del draft creado.
- ``error`` (str): Mensaje de error si ocurrió.
@@ -792,7 +1038,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
Draft creado exitosamente::
body = _build_notification_body(
"created", factura, tabla_html, doc_entry=2021
"created", factura, tabla_html, valref_html, doc_entry=2021
)
# → "<p>✅ Draft SAP B1 creado exitosamente.</p>..."
"""
@@ -800,12 +1046,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
f"<p><b>Factura:</b> {factura.get('serie')}-{factura.get('numero')}</p>"
f"<p><b>Emisor:</b> {factura.get('razon_social_emisor', '')}</p>"
)
valref_section = f"<h3>Valor Referencial (XML vs SAP)</h3>{valor_referencial_table}"
if status == "created":
return f"<p>✅ Draft SAP B1 creado exitosamente.</p><p><b>DocEntry:</b> {kwargs.get('doc_entry')}</p>{base}<h3>Validación de precios</h3>{validation_table}"
return f"<p>✅ Draft SAP B1 creado exitosamente.</p><p><b>DocEntry:</b> {kwargs.get('doc_entry')}</p>{base}<h3>Validación de precios</h3>{validation_table}{valref_section}"
elif status == "error":
return f"<p>❌ Error al crear Draft SAP B1.</p>{base}<p><b>Error:</b> {kwargs.get('error')}</p><h3>Validación de precios</h3>{validation_table}"
return f"<p>❌ Error al crear Draft SAP B1.</p>{base}<p><b>Error:</b> {kwargs.get('error')}</p><h3>Validación de precios</h3>{validation_table}{valref_section}"
else:
return f"<p>⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.</p>{base}<h3>Validación de precios</h3>{validation_table}"
return f"<p>⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.</p>{base}<h3>Validación de precios</h3>{validation_table}{valref_section}"
def _guardar_draft_json(draft: dict, serie: str, numero: str) -> None:
"""
@@ -833,26 +1080,26 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
filename = f"draft_sapb1_{serie}-{numero}_{ts}.json"
with open(os.path.join(output_folder, filename), "w", encoding="utf-8") as f:
json.dump(draft, f, ensure_ascii=False, indent=4)
print(f" 💾 Draft guardado: {filename}")
_log(f"💾 Draft guardado: {filename}")
# ========================================================================
# FLUJO DEL CORREO
# ========================================================================
print(f"\n📬 [{email['fecha']}] {email['subject'][:80]}...")
print(f" ├── 📂 Adjuntos:")
_log("📂 Adjuntos:")
# 1-2. BD: verificar existencia y guardar
exists = database_utilities.get_existing_ids([email["id"]])
if email["id"] in exists:
print(f" ├── ⏭️ Correo ya procesado, saltando")
_log("⏭️ Correo ya procesado, saltando")
return
database_utilities.save_emails([email])
# 3. Descargar adjuntos
adjuntos = _descargar_adjuntos()
if not adjuntos:
print(f" ├── ⏭️ Correo sin adjuntos")
_log("⏭️ Correo sin adjuntos")
return
# 4. Descomprimir ZIPs
@@ -868,49 +1115,67 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
# 7. Procesar XMLs
documentos = _procesar_xmls(pdf_cache)
if not documentos:
print(f" ├── ⏭️ No se extrajeron documentos XML")
_log("⏭️ No se extrajeron documentos XML")
return
# 8. Guardar resultado con todos los documentos
_guardar_resultado(documentos)
# 8. Completar guías faltantes desde PDF
n_guias = _completar_guias(documentos, pdf_cache)
if n_guias:
_log(f"🔄 {n_guias} guías completadas desde PDF")
# 9. Filtrar solo facturas para SAP
# 9. Validar que toda guía tenga tipo, serie y número
_validar_guias(documentos)
# 10. Guardar resultado con todos los documentos
paths_resultado = _guardar_resultado(documentos, email.get("datos_pdf", []))
email["resultado_paths"] = paths_resultado
# 11. Filtrar solo facturas para SAP
facturas = [d for d in documentos if d.get("tipo") == "01"]
if not facturas:
print(f" ├── ⏭️ No hay facturas (tipo 01) para SAP")
_log("⏭️ No hay facturas (tipo 01) para SAP")
return
for factura in facturas:
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
# 10. Validar precios (consulta SAP DNs, NO requiere draft)
_log(f"📄 Factura: {serie}-{numero}")
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = _validate_prices(factura)
validation_table = _build_validation_html(resultados_val)
# 11. Construir draft (usa LineTotal de los DNs, no del XML)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = _obtener_valor_referencial_sap(factura)
valor_referencial_table = _build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = _build_sap_draft(factura, resultados_val)
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
# 12. Guardar draft JSON (siempre, para depuración)
# 14. Guardar draft JSON (siempre, para depuración)
_guardar_draft_json(draft, serie, numero)
# 13. Crear draft en SAP o generar HTML
# 15. Crear draft en SAP o generar HTML
if todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
print(f" ✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
html_body = _build_notification_body("created", factura, validation_table, doc_entry=doc_entry)
_log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
html_body = _build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
print(f" ❌ Error creando draft {serie}-{numero}: {ex}")
html_body = _build_notification_body("error", factura, validation_table, error=ex)
_log(f"❌ Error creando draft {serie}-{numero}: {ex}")
html_body = _build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
else:
print(f" ⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table)
_log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 14. Enviar notificación
# 16. Enviar notificación
if token:
outlook_utilities.send_email(
token, email_addr,
+75 -6
View File
@@ -5,6 +5,7 @@ import os
import json
from typing import Optional, Dict, Any
from dotenv import load_dotenv
import hana_manager
# ============================================================================
@@ -13,7 +14,7 @@ from dotenv import load_dotenv
urllib3.disable_warnings()
load_dotenv(dotenv_path=".env.dev")
load_dotenv(dotenv_path=".env")
SAPB1_URL = os.getenv("SAP_B1_URL", "")
SAPB1_USER = os.getenv("SAP_B1_USER", "")
@@ -206,17 +207,85 @@ def get_purchase_invoice(doc_entry: int) -> dict:
# OBTENER GUIA (GET /DeliveryNotes)
# ============================================================================
def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
"""
Obtiene DeliveryNotes de SAP B1.
Fase 1 — Busca DocEntry/DocNum/CardCode/CardName:
- Primero intenta Service Layer con $select.
- Si no hay resultados, fallback a HANA (consulta ODLN con LIKE).
Fase 2 — Por cada DocEntry obtenido, hace GET /DeliveryNotes({DocEntry})
para recuperar el documento completo.
Returns:
list[dict] — Documentos completos de DeliveryNotes, o [] si no se
encontró nada en ninguna fuente.
"""
codigo = f"{serie}{tipo}-{numero}"
# ── Fase 1: Obtener DocEntries ──────────────────────────────────────
entries = _get_delivery_entries(tipo, serie, numero)
if not entries:
print(f" │ ⚠️ Guía {codigo}: sin registros en SAP (SL + HANA)")
return []
# ── Fase 2: Obtener documento completo por cada DocEntry ─────────────
docs = []
for entry in entries:
doc_entry = entry["DocEntry"]
try:
resp = forward_to_sapb1("GET", f"DeliveryNotes({doc_entry})")
doc = resp.json()
docs.append(doc)
print(f" │ └─ DeliveryNote({doc_entry}) obtenido")
except Exception as e:
print(f" │ ⚠️ Error obteniendo DeliveryNote({doc_entry}): {e}")
return docs
def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName}, ...]."""
codigo = f"{serie}{tipo}-{numero}"
# ── Intentar Service Layer ───────────────────────────────────────────
try:
filter_str = (
f"Cancelled eq 'tNO'"
f" and U_BPP_MDSD eq '{tipo}'"
f" and U_BPP_MDTD eq '{serie}'"
f" and contains(U_BPP_MDCD,'{numero}')"
)
resp = forward_to_sapb1("GET", "DeliveryNotes", params={"$filter": filter_str})
result = resp.json()
value = result.get("value", [])
print(f" ├── ✅ DeliveryNotes obtenida ({len(value)} registros)")
return value
resp = forward_to_sapb1(
"GET", "DeliveryNotes",
params={
"$filter": filter_str,
"$select": "DocEntry,DocNum,CardCode,CardName",
"$top": "10",
},
)
sl_entries = resp.json().get("value", [])
if sl_entries:
print(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
return sl_entries
print(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
except Exception as e:
print(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
# ── Fallback a HANA ──────────────────────────────────────────────────
try:
hana_entries = hana_manager.get_delivery(tipo, serie, numero)
if hana_entries:
print(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados")
return hana_entries
print(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
except Exception as e:
print(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
return []
# ============================================================================
# OBTENER ZONAS (GET /U_IDC_ZONAS)
+215 -14
View File
@@ -11,11 +11,39 @@ import xml.etree.ElementTree as ET
from dataclasses import dataclass, asdict, field
from typing import Optional, List
# ============================================================================
# Patrones regex organizados por tipo de extracción
# ============================================================================
# Serie-Número: busca cualquier patrón T001-123456, V001-789, etc.
SERIE_NUMERO_PATTERNS = [
r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b',
]
# Guías desde Notes de InvoiceLine: cada entrada es (patron, tipo_documento)
GUIAS_NOTES_PATTERNS = [
# GRE: "GRE REMITENTE: T001-128912", "GRR.T001-129352", "G.R.R:T001-132131"
(r"(?:GRE\s+REMITENTE|GRR|G\.R\.R)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-\s*(\d+)", "09"),
# GRT: "GRT:V001-194", "GRT.V001-227", "G.R.T: EG03-4643"
(r"\b(?:GRT|G\.R\.T)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-\s*(\d+)", "31"),
]
# Valor referencial desde la descripción del Item: cada cliente lo redacta distinto,
# por lo que se prueba una lista de patrones en orden hasta encontrar el primero que calce.
# Agregar aquí nuevas variantes a medida que aparezcan en facturas de otros clientes.
VALOR_REFERENCIAL_PATTERNS = [
r"VALOR\s+REFERENCIAL\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VALOR REFERENCIAL: S/ 2,345.00" / "VALOR REFERENCIAL: 468.86"
r"VALOR\s+REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VALOR REF:S/ 1,139.75"
r"VAL\.?\s*REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VAL.REF.:S/700.25"
r"V\.?\s*REF\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "V.REF: S/ 2940.80 TN: 36.85"
r"\bV\.\s*R\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "V.R. S/964.51" / "V.R.: 1045.20"
r"\bVR\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VR:S/300.10"
]
# ============================================================================
# Namespaces UBL 2.1 utilizados en los XML de SUNAT
NAMESPACES = {
"inv": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"desp": "urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2",
"ar": "urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2",
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
"ext": "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
@@ -47,6 +75,10 @@ class DetalleLinea:
cantidad: float
precio_unitario: float
total_linea: float = 0.0
notes_text: str = ""
guias_remision: List[dict] = field(default_factory=list)
guia_transportista: Optional[dict] = None
valor_referencial: Optional[float] = None
@dataclass
@@ -123,6 +155,26 @@ class DatosComprobante:
# ============================================================================
def _inferir_tipo_desde_serie(serie: str) -> str:
"""
Infiere el tipo de documento SUNAT desde el prefijo de la serie.
- Serie que empieza con 'T' → tipo 09 (Guía de Remisión Remitente)
- Serie que empieza con 'V' o 'E' → tipo 31 (Guía de Remisión Transportista)
- Otros → tipo 09 por defecto
"""
if serie and serie[0] == "T":
return "09"
if serie and serie[0] in ("V", "E"):
return "31"
return "09"
def _es_referencia_valida(serie: str, numero: str) -> bool:
"""Valida que una referencia tenga serie no vacía y número > 0."""
return bool(serie) and numero not in ("", "0")
def _find_text(element: ET.Element, xpath: str, namespaces: dict) -> Optional[str]:
"""Busca un elemento por XPath y devuelve su texto, o None si no existe."""
node = element.find(xpath, namespaces)
@@ -131,39 +183,103 @@ def _find_text(element: ET.Element, xpath: str, namespaces: dict) -> Optional[st
return None
# ============================================================================
def split_serie_numero(comprobante_id: str) -> tuple[str, str]:
"""Public alias for _split_serie_numero."""
return _split_serie_numero(comprobante_id)
def _split_serie_numero(comprobante_id: str) -> tuple[str, str]:
"""
Separa el ID del comprobante en serie y número, removiendo ceros a la izquierda del número.
Ejemplo: 'F002-3091' -> ('F002', '3091')
'T001-00128143' -> ('T001', '128143')
'EG03-1752' -> ('EG03', '1752')
'T001 - 00130229' -> ('T001', '130229')
'0002 - 000608' -> ('0002', '608')
"""
if "-" in comprobante_id:
partes = comprobante_id.split("-", 1)
# Remueve ceros a la izquierda, si queda vacío (ej: '000') se mantiene '0'
numero = partes[1].lstrip("0") or "0"
return partes[0], numero
return comprobante_id, ""
serie = partes[0].strip()
# Remueve espacios y ceros a la izquierda, si queda vacío (ej: '000') se mantiene '0'
numero = partes[1].strip().lstrip("0") or "0"
return serie, numero
return comprobante_id.strip(), ""
# ============================================================================
def _extraer_serie_numero_desde_texto(texto: str) -> tuple[str, str] | None:
texto = re.sub(r'[–—−]', '-', texto)
patron = r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b'
for patron in SERIE_NUMERO_PATTERNS:
m = re.search(patron, texto)
if m:
return m.group(1).upper(), m.group(2)
numero = m.group(2).lstrip("0") or "0"
return m.group(1).upper(), numero
return None
def _numeros_extra_guia(texto: str, pos: int) -> list[str]:
"""
Desde una posición devuelve números adicionales que comparten la misma serie
de una guía ya capturada, separados por `,`, `/`, `\\` o `;`.
Ejemplo: ``"T001-133023,133024"`` con ``pos`` tras ``133023`` → ``["133024"]``
"""
numeros = []
resto = texto[pos:]
while True:
m = re.match(r'\s*[,;\\/]\s*(\d+)', resto)
if not m:
break
numeros.append(m.group(1).lstrip("0") or "0")
resto = resto[m.end():]
return numeros
def extraer_guias_desde_notes(notes_text: str) -> list[dict]:
"""
Extrae guías de remisión (GRE/GRT) desde el texto de Notes de una InvoiceLine.
Retorna lista de dicts con ``tipo``, ``serie``, ``numero``.
"""
resultados = []
for patron, tipo in GUIAS_NOTES_PATTERNS:
for m in re.finditer(patron, notes_text, re.IGNORECASE):
serie = m.group(1).upper()
numero = m.group(2).lstrip("0") or "0"
resultados.append({"tipo": tipo, "serie": serie, "numero": numero})
for extra in _numeros_extra_guia(notes_text, m.end()):
resultados.append({"tipo": tipo, "serie": serie, "numero": extra})
return resultados
def extraer_valor_referencial(texto: str) -> Optional[float]:
"""
Extrae el valor referencial (importe) desde el texto de descripción de un Item.
Prueba cada patrón de ``VALOR_REFERENCIAL_PATTERNS`` en orden y devuelve el
primer importe encontrado.
Ejemplos: ``"...VALOR REF:S/ 1,139.75"`` -> ``1139.75``
``"...V.R. S/964.51"`` -> ``964.51``
"""
for patron in VALOR_REFERENCIAL_PATTERNS:
m = re.search(patron, texto, re.IGNORECASE)
if m:
return float(m.group(1).replace(",", ""))
return None
def obtener_documentos_referencia_detalle_factura(texto: str) -> list[str]:
texto = re.sub(r'[–—−]', '-', texto)
patron = r'\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b'
return list(dict.fromkeys(
f"{serie.upper()}-{numero}"
for serie, numero in re.findall(patron, texto, flags=re.IGNORECASE)
))
resultados = []
for patron in SERIE_NUMERO_PATTERNS:
for m in re.finditer(patron, texto, flags=re.IGNORECASE):
serie = m.group(1).upper()
numero = m.group(2)
resultados.append(f"{serie}-{numero}")
for extra in _numeros_extra_guia(texto, m.end()):
resultados.append(f"{serie}-{extra}")
return list(dict.fromkeys(resultados))
# ============================================================================
def _parse_invoice(root: ET.Element) -> DatosComprobante:
"""Extrae datos de un documento Invoice (Factura/Boleta)."""
@@ -228,7 +344,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
ref_serie, ref_numero = parsed
else:
ref_serie, ref_numero = _split_serie_numero(ref_id)
documentos_referencia.append(DocumentoReferencia(tipo=ref_tipo, serie=ref_serie, numero=ref_numero))
if _es_referencia_valida(ref_serie, ref_numero):
tipo_ref = ref_tipo if ref_tipo else _inferir_tipo_desde_serie(ref_serie)
documentos_referencia.append(DocumentoReferencia(tipo=tipo_ref, serie=ref_serie, numero=ref_numero))
# si los documentos de referencia non estan en los tags de SUNAT
# buscamos estos documentos en el cuerpo de la factura
@@ -242,7 +360,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
guias = obtener_documentos_referencia_detalle_factura(description.text)
if guias:
for guia in guias:
documentos_referencia.append(DocumentoReferencia(tipo="09" if guia[:1] in ("T", "V", "E") else "31", serie=_split_serie_numero(guia)[0], numero=_split_serie_numero(guia)[1]))
serie_parsed, numero_parsed = _split_serie_numero(guia)
if _es_referencia_valida(serie_parsed, numero_parsed):
documentos_referencia.append(DocumentoReferencia(tipo=_inferir_tipo_desde_serie(serie_parsed), serie=serie_parsed, numero=numero_parsed))
# Inicializar campos de detracción y líneas
tiene_detraccion = False
@@ -253,6 +373,12 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
# Solo si el tipo es Factura (01) se extraen detracciones y líneas detalladas
if tipo == "01":
# Notas a nivel de documento (cbc:Note en la raíz): algunos clientes ponen
# el valor referencial aquí en vez de en la descripción del Item, ej:
# "V.REF: S/ 2940.80 TN: 36.85"
root_notes = root.findall("cbc:Note", ns)
root_notes_text = " | ".join(n.text.strip() for n in root_notes if n.text) if root_notes else ""
# Buscar detracción en PaymentTerms
for pt_node in root.findall("cac:PaymentTerms", ns):
pt_id = _find_text(pt_node, "cbc:ID", ns)
@@ -271,6 +397,9 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
for line_node in root.findall("cac:InvoiceLine", ns):
descripcion = _find_text(line_node, "cac:Item/cbc:Description", ns) or ""
notes = line_node.findall("cbc:Note", ns)
notes_text = " | ".join(n.text.strip() for n in notes if n.text) if notes else ""
qty_str = _find_text(line_node, "cbc:InvoicedQuantity", ns)
cantidad = float(qty_str) if qty_str else 0.0
@@ -284,12 +413,26 @@ def _parse_invoice(root: ET.Element) -> DatosComprobante:
total_linea_str = _find_text(line_node, "cbc:LineExtensionAmount", ns)
total_linea = float(total_linea_str) if total_linea_str else 0.0
guias = extraer_guias_desde_notes(notes_text)
guias_remision = [g for g in guias if g["tipo"] == "09"]
gt = [g for g in guias if g["tipo"] == "31"]
valor_referencial = (
extraer_valor_referencial(descripcion)
or extraer_valor_referencial(notes_text)
or extraer_valor_referencial(root_notes_text)
)
lineas.append(
DetalleLinea(
descripcion=descripcion,
cantidad=cantidad,
precio_unitario=precio_unitario,
total_linea=total_linea,
notes_text=notes_text,
guias_remision=guias_remision,
guia_transportista=gt[0] if gt else None,
valor_referencial=valor_referencial,
)
)
@@ -455,6 +598,62 @@ def _parse_despatch_advice(root: ET.Element) -> DatosComprobante:
)
# ============================================================================
def _parse_application_response(root: ET.Element) -> dict:
"""Extrae datos de un ApplicationResponse (CDR de SUNAT)."""
ns = NAMESPACES
ruc_emisor = ""
tipo = ""
serie = ""
numero = ""
# RUC del emisor original desde ReceiverParty
ruc_emisor = (
_find_text(root, "cac:ReceiverParty/cac:PartyIdentification/cbc:ID", ns)
or ""
)
# Tipo, serie y número desde DocumentResponse/DocumentReference
for doc_resp in root.findall(".//cac:DocumentResponse", ns):
# DocumentTypeCode del documento original
if not tipo:
tipo = (
_find_text(doc_resp, "cac:DocumentReference/cbc:DocumentTypeCode", ns)
or ""
)
# ID del documento original (ej: FF01-3308, F002-20851)
ref_id = (
_find_text(doc_resp, "cac:DocumentReference/cbc:ID", ns)
or ""
)
if ref_id:
serie, numero = _split_serie_numero(ref_id)
# Si no hay tipo, intentar inferir desde el texto de la descripción
if not tipo:
desc = _find_text(doc_resp, "cac:Response/cbc:Description", ns) or ""
m = re.search(r"\b(?:FACTURA|Factura)\b", desc)
if m:
tipo = "01"
# Si no hay RUC, intentar extraer desde la descripción
if not ruc_emisor:
desc = _find_text(doc_resp, "cac:Response/cbc:Description", ns) or ""
m = re.search(r"\b(\d{11})-\d{2}-", desc)
if m:
ruc_emisor = m.group(1)
return {
"ruc_emisor": ruc_emisor,
"tipo": tipo,
"serie": serie,
"numero": numero,
"es_cdr": True,
"tipo_descripcion": "CDR",
}
def extraer_datos_xml(ruta_xml: str) -> Optional[dict]:
"""
Recibe la ruta de un archivo XML de comprobante electrónico SUNAT.
@@ -478,6 +677,8 @@ def extraer_datos_xml(ruta_xml: str) -> Optional[dict]:
tag = root.tag
if "ApplicationResponse" in tag:
return _parse_application_response(root)
if "Invoice" in tag:
tipo = _find_text(root, "inv:InvoiceTypeCode", NAMESPACES) or _find_text(root, "cbc:InvoiceTypeCode", NAMESPACES) or ""
if tipo == "01":