se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fcc80d3c6e
commit
e6cde62e72
14 files changed
+1672
-198
No files matched your search
@@ -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=")
|
||||
@@ -0,0 +1,95 @@
|
||||
# Inventario del código
|
||||
|
||||
## Archivos
|
||||
|
||||
| Archivo | Líneas | Rol |
|
||||
|---------|--------|-----|
|
||||
| `main.py` | 134 | Demonio orquestador (loop de horarios) |
|
||||
| `pipeline.py` | 957 | Pipeline core (procesar un correo completo) |
|
||||
| `sunat_schema.py` | 521 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
||||
| `pdf_utilities.py` | 450 | Parseo de PDFs (factura, guías) |
|
||||
| `sapb1_manager.py` | 322 | Comunicación con SAP B1 Service Layer |
|
||||
| `outlook_utilities.py` | 228 | Comunicación con Microsoft Graph |
|
||||
| `hana_manager.py` | 126 | Comunicación con SAP HANA directa |
|
||||
| `database_utilities.py` | 114 | SQLite local (cache de correos) |
|
||||
| `consulta_rapida.py` | 115 | Script auxiliar para reprocesar correos por ID |
|
||||
| `email_utilities.py` | 93 | Utilitarios de adjuntos (ZIP, filtrado) |
|
||||
| `logger_utilities.py` | 72 | Logging a archivo + consola |
|
||||
|
||||
---
|
||||
|
||||
## Dependencias entre archivos
|
||||
|
||||
```
|
||||
main.py
|
||||
└── pipeline.py
|
||||
├── sunat_schema.py
|
||||
├── pdf_utilities.py
|
||||
├── sapb1_manager.py
|
||||
│ └── hana_manager.py
|
||||
├── outlook_utilities.py
|
||||
├── email_utilities.py
|
||||
│ └── database_utilities.py
|
||||
└── database_utilities.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flujo de datos
|
||||
|
||||
```
|
||||
main()
|
||||
└── run_pipeline()
|
||||
├── step_authenticate() → token MS Graph
|
||||
├── step_fetch_emails() → emails[]
|
||||
└── _procesar_un_correo() (por cada email)
|
||||
├── BD: verificar duplicado
|
||||
├── _descargar_adjuntos() → archivos
|
||||
├── _descomprimir() ZIPs
|
||||
├── _filtrar_y_limpiar() → solo PDF/XML
|
||||
├── _procesar_pdfs() → pdf_cache
|
||||
│ └── pdf_utilities.extraer_datos_pdf()
|
||||
├── _procesar_xmls(pdf_cache) → documentos[]
|
||||
│ └── sunat_schema.extraer_datos_xml()
|
||||
├── _guardar_resultado(documentos)
|
||||
├── _validate_prices(factura) → (ok, resultados)
|
||||
│ └── sapb1_manager.get_delivery_notes()
|
||||
│ ├── SL (Service Layer) GET /DeliveryNotes
|
||||
│ └── HANA fallback (get_delivery)
|
||||
├── _build_sap_draft(factura, resultados) → draft
|
||||
├── _guardar_draft_json(draft)
|
||||
├── sapb1_manager.create_draft(draft)
|
||||
└── outlook_utilities.send_email(notificación)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problemas conocidos hasta ahora
|
||||
|
||||
| # | Problema | Archivo | Estado |
|
||||
|---|----------|---------|--------|
|
||||
| 1 | `_extraer_serie_numero_desde_texto` no limpiaba leading zeros | `sunat_schema.py` | ✅ Corregido |
|
||||
| 2 | Al asignar guías por línea, si hay +refs que líneas, las sobrantes se perdían | `pipeline.py` | ✅ Corregido |
|
||||
| 3 | SL sin fallback a HANA | `sapb1_manager.py` | ✅ Corregido |
|
||||
| 4 | Ctrl+C mostraba traceback | `main.py` | ✅ Corregido |
|
||||
| 5 | `.env.dev` vs `.env` inconsistente | `sapb1_manager.py` | ✅ Corregido |
|
||||
| 6 | `HANA_HOST` vs `HANA_SERVER` en `.env` | `hana_manager.py` | ✅ Corregido |
|
||||
| 7 | Columna `Cancelled` vs `CANCELED` en HANA | `hana_manager.py` | ✅ Corregido |
|
||||
|
||||
---
|
||||
|
||||
## Funciones internas en `_procesar_un_correo` (pipeline.py)
|
||||
|
||||
| Función | Líneas | Propósito |
|
||||
|---------|--------|-----------|
|
||||
| `_descargar_adjuntos` | 107-134 | Descarga adjuntos vía Graph API |
|
||||
| `_descomprimir` | 136-149 | Descomprime ZIPs |
|
||||
| `_filtrar_y_limpiar` | 151-191 | Filtra PDF/XML, elimina el resto |
|
||||
| `_procesar_pdfs` | 193-246 | Extrae datos de PDFs, renombra, genera cache |
|
||||
| `_procesar_xmls` | 248-377 | Procesa XMLs, cruza con PDF, asigna guías |
|
||||
| `_guardar_resultado` | 379-408 | Guarda JSON con datos extraídos |
|
||||
| `_build_sap_draft` | 410-569 | Construye payload del draft SAP B1 |
|
||||
| `_validate_prices` | 571-745 | Valida precios contra DeliveryNotes SAP |
|
||||
| `_build_validation_html` | 747-775 | Genera tabla HTML para notificación |
|
||||
| `_build_notification_body` | 777-814 | Arma cuerpo del email de notificación |
|
||||
| `_guardar_draft_json` | 816-842 | Guarda draft JSON para depuración |
|
||||
@@ -0,0 +1,92 @@
|
||||
# main.py — Mapa del flujo
|
||||
|
||||
## Estructura general
|
||||
|
||||
```
|
||||
main.py
|
||||
├── Constantes: HORARIOS (4 bloques horarios)
|
||||
├── obtener_intervalo(dt) → (intervalo, modo) | None
|
||||
├── segundos_hasta_proximo_inicio(dt) → int
|
||||
├── non_negative_int(value) → int (validador argparse)
|
||||
└── main() → None
|
||||
```
|
||||
|
||||
## Proceso de `main()`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Inicio main] --> B[Parsear args --dias]
|
||||
B --> C[Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER]
|
||||
C --> D{Bucle infinito}
|
||||
|
||||
D --> E[inicio_ciclo = datetime.now]
|
||||
E --> F[obtener_intervalo inicio_ciclo]
|
||||
|
||||
F -- Sí, está en horario --> G[Extraer intervalo, modo]
|
||||
G --> H[run_pipeline]:::highlight
|
||||
H --> I[Calcular duración real]
|
||||
I --> J{Duración > intervalo?}
|
||||
J -- Sí --> K[⚠️ Warning]
|
||||
J -- No --> L[dormir = intervalo - duración]
|
||||
K --> L
|
||||
L --> M[💤 Próximo ciclo en dormir seg]
|
||||
M --> N[time.sleep dormir]
|
||||
N --> E
|
||||
|
||||
F -- No, fuera de horario --> O[dormir = segundos_hasta_proximo_inicio]
|
||||
O --> P[Log próxima ejecución]
|
||||
P --> N
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
### Paso a paso
|
||||
|
||||
```
|
||||
main()
|
||||
│
|
||||
├── 1. Parsear args (--dias, default=2)
|
||||
│
|
||||
├── 2. Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER
|
||||
│
|
||||
├── 3. LOOP INFINITO (while True):
|
||||
│ │
|
||||
│ ├── 3.1. inicio_ciclo = datetime.now()
|
||||
│ │
|
||||
│ ├── 3.2. resultado = obtener_intervalo(inicio_ciclo)
|
||||
│ │ │
|
||||
│ ├── ¿Sí está en horario?
|
||||
│ │ │
|
||||
│ │ ├── Extraer (intervalo, modo)
|
||||
│ │ ├── run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
|
||||
│ │ ├── Calcular duración real del ciclo
|
||||
│ │ ├── ⚠️ Si duración > intervalo → warning
|
||||
│ │ └── dormir = max(0, intervalo - duración)
|
||||
│ │
|
||||
│ └── ¿No está en horario?
|
||||
│ │
|
||||
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente)
|
||||
│ └── Log "Fuera del horario, próxima ejecución en..."
|
||||
│
|
||||
└── 4. time.sleep(dormir) → vuelve a 3.1
|
||||
```
|
||||
|
||||
## Bloques horarios definidos
|
||||
|
||||
| Desde | Hasta | Intervalo | Modo |
|
||||
|---------|---------|-----------|----------|
|
||||
| 06:30 | 13:15 | 300s | rápido |
|
||||
| 13:15 | 14:00 | 1800s | full |
|
||||
| 14:00 | 18:30 | 300s | rápido |
|
||||
| 18:30 | 21:30 | 3600s | full |
|
||||
|
||||
Fuera de ese rango → duerme hasta las 06:30 del día siguiente.
|
||||
|
||||
## Entry point (línea 121-130)
|
||||
|
||||
```
|
||||
if __name__ == "__main__":
|
||||
1. logger_utilities.setup_logger()
|
||||
2. logger_utilities.limpiar_logs_antiguos(dias=30)
|
||||
3. main()
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# pipeline.py — run_pipeline
|
||||
|
||||
## Estructura general
|
||||
|
||||
```
|
||||
pipeline.py
|
||||
├── CONSTANTES: CUADRO_VALOR_REFERENCIAL
|
||||
├── HELPERS: _ruta_carpeta()
|
||||
├── PASO 1: step_authenticate() → token | None
|
||||
├── PASO 2: step_fetch_emails() → list[dict]
|
||||
├── _procesar_un_correo() → None (core)
|
||||
└── run_pipeline() → None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proceso de `run_pipeline()`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[run_pipeline] --> B[init_db]
|
||||
B --> C[Calcular rango fechas: ayer-dias → hoy]
|
||||
C --> D[step_authenticate]
|
||||
D --> E{Token obtenido?}
|
||||
E -- No --> F[❌ Abortar]
|
||||
E -- Sí --> G[step_fetch_emails]
|
||||
G --> H{Hay correos?}
|
||||
H -- No --> I[Fin]
|
||||
H -- Sí --> J[Bucle: por cada email]
|
||||
J --> K[_procesar_un_correo]:::highlight
|
||||
K --> J
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proceso de `_procesar_un_correo()`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[_procesar_un_correo] --> B[1-2. BD: ¿ya procesado?]
|
||||
B -- Sí --> C[⏭️ Saltar]
|
||||
B -- No --> D[Guardar en BD]
|
||||
D --> E[3. _descargar_adjuntos]
|
||||
E --> F{Hay adjuntos?}
|
||||
F -- No --> C
|
||||
F -- Sí --> G[4. _descomprimir ZIPs]
|
||||
G --> H[5. _filtrar_y_limpiar]
|
||||
H --> I{Hay PDF/XML?}
|
||||
I -- No --> C
|
||||
I -- Sí --> J[6. _procesar_pdfs]:::highlight
|
||||
J --> K[7. _procesar_xmls]:::highlight
|
||||
K --> L{Hay documentos?}
|
||||
L -- No --> C
|
||||
L -- Sí --> M[8. _guardar_resultado]
|
||||
M --> N[9. Filtrar facturas tipo 01]
|
||||
N --> O{Hay facturas?}
|
||||
O -- No --> C
|
||||
O -- Sí --> P[Bucle: por cada factura]
|
||||
|
||||
P --> Q[10. _validate_prices]:::highlight
|
||||
Q --> R[11. _build_sap_draft]:::highlight
|
||||
R --> S[12. _guardar_draft_json]
|
||||
S --> T{Todas coinciden?}
|
||||
T -- Sí --> U[13. sapb1_manager.create_draft]
|
||||
T -- No --> V[⏭️ Draft NO creado]
|
||||
U --> W[14. outlook_utilities.send_email]
|
||||
V --> W
|
||||
W --> P
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Funciones internas de `_procesar_un_correo`
|
||||
|
||||
| Función | Línea | Propósito |
|
||||
|---------|-------|-----------|
|
||||
| `_descargar_adjuntos()` | 107 | Descarga adjuntos vía Microsoft Graph |
|
||||
| `_descomprimir()` | 136 | Descomprime archivos ZIP |
|
||||
| `_filtrar_y_limpiar()` | 151 | Filtra solo PDF/XML, elimina el resto |
|
||||
| **`_procesar_pdfs()`** | **193** | **Extrae datos de PDFs, renombra, genera cache** |
|
||||
| **`_procesar_xmls(pdf_cache)`** | **248** | **Procesa XMLs, cruza con PDF, asigna guías** |
|
||||
| `_guardar_resultado(documentos)` | 372 | Guarda JSON con datos extraídos |
|
||||
| **`_build_sap_draft(factura, resultados_val)`** | **403** | **Construye payload del draft SAP B1** |
|
||||
| **`_validate_prices(factura)`** | **564** | **Valida precios contra DeliveryNotes SAP** |
|
||||
| `_build_validation_html(resultados)` | 741 | Genera tabla HTML para notificación |
|
||||
| `_build_notification_body(status, factura, validation_table)` | 771 | Arma cuerpo del email de notificación |
|
||||
| `_guardar_draft_json(draft, serie, numero)` | 810 | Guarda draft JSON para depuración |
|
||||
|
||||
---
|
||||
|
||||
## Flujo de llamadas
|
||||
|
||||
```
|
||||
run_pipeline
|
||||
├── step_authenticate
|
||||
├── step_fetch_emails
|
||||
└── _procesar_un_correo (por cada email)
|
||||
├── _descargar_adjuntos
|
||||
├── _descomprimir
|
||||
├── _filtrar_y_limpiar
|
||||
├── _procesar_pdfs
|
||||
├── _procesar_xmls
|
||||
├── _guardar_resultado
|
||||
├── _validate_prices (por factura)
|
||||
│ └── sapb1_manager.get_delivery_notes
|
||||
├── _build_sap_draft
|
||||
├── _guardar_draft_json
|
||||
├── sapb1_manager.create_draft
|
||||
└── outlook_utilities.send_email
|
||||
```
|
||||
@@ -1,29 +0,0 @@
|
||||
# Pipeline Completo
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["📬 Correo recibido"] --> B{"1-2. BD check<br>¿Ya procesado?"}
|
||||
B -->|"Sí"| C["⏭️ Saltar correo"]
|
||||
B -->|"No"| D["3. Descargar adjuntos<br>Microsoft Graph"]
|
||||
D --> E{"¿Hay adjuntos?"}
|
||||
E -->|No| C
|
||||
E -->|Sí| F["4. Descomprimir ZIPs"]
|
||||
F --> G["5. Filtrar solo PDF/XML"]
|
||||
G --> H{"¿Quedan válidos?"}
|
||||
H -->|No| C
|
||||
H -->|Sí| I["6. Procesar PDFs<br>extraer datos + renombrar"]
|
||||
I --> J["7. Procesar XMLs<br>cruzar con PDF cache"]
|
||||
J --> K["8. Guardar resultado JSON"]
|
||||
K --> L["9. Filtrar facturas tipo 01"]
|
||||
L --> M{"¿Hay facturas?"}
|
||||
M -->|No| N["⏭️ Sin facturas para SAP"]
|
||||
M -->|Sí| O["10. Validar precios<br>_validate_prices()"]
|
||||
O --> P["11. Construir draft SAP<br>_build_sap_draft()"]
|
||||
P --> Q["12. Guardar draft JSON"]
|
||||
Q --> R{"13. ¿Precios coinciden?"}
|
||||
R -->|Sí| S["Crear Draft en SAP B1<br>POST /Drafts"]
|
||||
R -->|No| T["Notificar: validación falló"]
|
||||
S --> U["14. Enviar email notificación"]
|
||||
T --> U
|
||||
N --> U
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
# _procesar_pdfs — Extracción de datos desde PDFs
|
||||
|
||||
## Ubicación
|
||||
|
||||
- **Archivo:** `pipeline.py` (línea 193-246)
|
||||
- **Librería:** `pdf_utilities.py` (línea 1-450)
|
||||
|
||||
---
|
||||
|
||||
## Flujo general
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[_procesar_pdfs] --> B[Iterar adjuntos del email]
|
||||
B --> C{Es .pdf?}
|
||||
C -- No --> B
|
||||
C -- Sí --> D[extraer_datos_pdf]:::highlight
|
||||
D --> E{Hay datos sin error?}
|
||||
E -- No --> F[⚠️ Log error]
|
||||
F --> B
|
||||
E -- Sí --> G[renombrar_pdf]:::highlight
|
||||
G --> H[Actualizar email.adjuntos]
|
||||
H --> I[Acumular en datos_pdf]
|
||||
I --> J[Guardar en pdf_cache]
|
||||
J --> B
|
||||
|
||||
B -- Fin --> K{datos_pdf vacío?}
|
||||
K -- No --> L[email.datos_pdf = datos_pdf]
|
||||
K -- Sí --> M[return pdf_cache]
|
||||
L --> M
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso a paso
|
||||
|
||||
```
|
||||
_procesar_pdfs() → dict (pdf_cache)
|
||||
│
|
||||
├── carpeta = _ruta_carpeta(download_folder)
|
||||
│
|
||||
├── Por cada adjunto en email["adjuntos"]:
|
||||
│ │
|
||||
│ ├── ¿No termina en ".pdf"? → continue
|
||||
│ │
|
||||
│ ├── pdf_path = carpeta + adjunto
|
||||
│ │
|
||||
│ ├── extraer_datos_pdf(pdf_path) → data
|
||||
│ │ └── (ver detalle abajo)
|
||||
│ │
|
||||
│ ├── ¿data válido y sin error?
|
||||
│ │ │
|
||||
│ │ ├── renombrar_pdf(pdf_path, data) → nuevo_path
|
||||
│ │ │ └── formato: {ruc}-{tipo}-{serie}-{numero}.pdf
|
||||
│ │ │
|
||||
│ │ ├── Si cambió el nombre:
|
||||
│ │ │ ├── email["adjuntos"][idx] = nuevo_nombre
|
||||
│ │ │ └── data["archivo"] = nuevo_nombre
|
||||
│ │ │
|
||||
│ │ ├── datos_pdf.append(data)
|
||||
│ │ ├── pdf_cache[canonical] = data
|
||||
│ │ └── 📄 PDF procesado: {canonical}
|
||||
│ │
|
||||
│ └── ¿Error? → ⚠️ PDF no procesado: {error}
|
||||
│
|
||||
├── Si hay datos → email["datos_pdf"] = datos_pdf
|
||||
│
|
||||
└── return pdf_cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `extraer_datos_pdf(pdf_path)` `:highlight` — Detalle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[extraer_datos_pdf] --> B[pdfplumber.open]
|
||||
B --> C[Extraer texto de todas las páginas]
|
||||
C --> D[_extract_base_data]:::highlight
|
||||
D --> E{Qué tipo de documento?}
|
||||
|
||||
E -- FACTURA --> F[_extract_factura_details]:::highlight
|
||||
F --> G[_extract_detraccion_spatial]
|
||||
|
||||
E -- GUIA DE REMISION --> H[_extract_guia_remision_details]:::highlight
|
||||
|
||||
E -- GUIA DE REMISION TRANSPORTISTA --> I[_extract_guia_transportista_details]:::highlight
|
||||
|
||||
E -- BOLETA / otro --> J[return data]
|
||||
|
||||
G --> J
|
||||
H --> J
|
||||
I --> J
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
### `_extract_base_data(text, path)` — Datos base
|
||||
|
||||
Extrae del texto plano del PDF mediante regex:
|
||||
|
||||
| Campo | Fuente |
|
||||
|-------|--------|
|
||||
| `ruc_emisor` | `R.U.C. {11 dígitos}` o nombre del archivo o primer RUC en el texto |
|
||||
| `tipo_documento` | "FACTURA ELECTRÓNICA" / "BOLETA DE VENTA" / "GUÍA DE REMISIÓN" / "GUÍA DE REMISIÓN TRANSPORTISTA" |
|
||||
| `serie_numero` | `{4 caracteres} - {números}` ej: `F002-486` |
|
||||
| `fecha_emision` | `dd/mm/yyyy` |
|
||||
| `ruc_receptor` | Segundo RUC de 11 dígitos encontrado |
|
||||
| `nombre_receptor` | Línea con `SEÑOR(ES) / CLIENTE / RAZÓN SOCIAL / ADQUIRIENTE` |
|
||||
| `monto_total` | `TOTAL / IMPORTE TOTAL / NETO A PAGAR` |
|
||||
|
||||
---
|
||||
|
||||
### `_extract_factura_details(text, data)` `:highlight` — Factura
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[_extract_factura_details] --> B[Buscar monto_total]
|
||||
B --> C[Inicializar items[] y detraccion]
|
||||
C --> D[Buscar cabecera de tabla]
|
||||
D --> E[Parsear filas de items]
|
||||
E --> F[Por cada item extraer:]
|
||||
F --> G[guias_remision]
|
||||
F --> H[guia_transportista]
|
||||
F --> I[bolsas]
|
||||
F --> J[peso / unidad_peso]
|
||||
J --> K[Buscar detraccion]
|
||||
K --> L[return]
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
**Items de tabla** (detecta ≥3 headers entre: CANT, DESCRIP, PRECIO, TOTAL, UNIT, VALOR, CÓDIGO, ITEM):
|
||||
|
||||
| Campo | Descripción |
|
||||
|-------|-------------|
|
||||
| `codigo` | Código alfanumérico del producto (opcional) |
|
||||
| `cantidad` | Cantidad numérica |
|
||||
| `descripcion` | Descripción del ítem |
|
||||
| `precio_unitario` | Precio unitario |
|
||||
| `total` | Total por línea |
|
||||
|
||||
**Por cada item**, parsea desde la descripción:
|
||||
|
||||
| Campo | Regex |
|
||||
|-------|-------|
|
||||
| `guias_remision[]` | `GRR / GR / G.R.R. / GUIA REMISION: {serie}-{num}` |
|
||||
| `guia_transportista` | `GRT / G.R.T. / GUIA TRANSPORTISTA: {serie}-{num}` |
|
||||
| `bolsas` | `{n} BOLSAS / BLS / SACOS` |
|
||||
| `peso` / `unidad_peso` | `PESO: {valor} {TN/KG}` |
|
||||
|
||||
**Detracción**: Detecta si tiene detracción, porcentaje y monto.
|
||||
|
||||
---
|
||||
|
||||
### `_extract_guia_remision_details(text, data)` `:highlight` — Guía de Remisión
|
||||
|
||||
Extrae del texto:
|
||||
|
||||
| Campo | Descripción |
|
||||
|-------|-------------|
|
||||
| `peso_total` | Peso Total |
|
||||
| `punto_partida` | Punto de Partida |
|
||||
| `punto_llegada` | Punto de Llegada |
|
||||
| `placa` | Placa del vehículo |
|
||||
| `marca` | Marca del vehículo |
|
||||
| `chofer` | Nombre del chofer |
|
||||
| `brevete` | N° de brevete |
|
||||
| `configuracion_vehicular` | Configuración vehicular |
|
||||
| `items[]` | Lista de {cantidad, unidad, descripcion} |
|
||||
|
||||
---
|
||||
|
||||
### `_extract_guia_transportista_details(text, data)` `:highlight` — Guía de Remisión Transportista
|
||||
|
||||
Extrae del texto:
|
||||
|
||||
| Campo | Descripción |
|
||||
|-------|-------------|
|
||||
| `punto_partida` | Punto de Partida |
|
||||
| `punto_llegada` | Punto de Llegada |
|
||||
| `nombre_remitente` / `ruc_remitente` | Datos del remitente |
|
||||
| `nombre_destinatario` / `ruc_destinatario` | Datos del destinatario |
|
||||
| `documentos_relacionados[]` | Documentos relacionados |
|
||||
| `peso_bruto_total_carga` | Peso bruto total |
|
||||
| `unidad_medida_peso_bruto` | Unidad de medida |
|
||||
| `indicador_pagador_flete` | Indicador del pagador |
|
||||
| `nombre_pagador` / `ruc_pagador` | Datos del pagador |
|
||||
| `datos_vehiculos[]` | Lista de {placa} |
|
||||
| `datos_conductores[]` | Lista de {nombre, brevete} |
|
||||
| `observaciones` | Observaciones |
|
||||
|
||||
---
|
||||
|
||||
## `renombrar_pdf(pdf_path, data)` `:highlight`
|
||||
|
||||
```
|
||||
renombrar_pdf(pdf_path, data) → nuevo_path
|
||||
│
|
||||
└── generar_nombre_pdf(data) → nombre canónico
|
||||
│
|
||||
├── ruc = data["ruc_emisor"]
|
||||
├── abrev = TIPO_MAP[data["tipo_documento"]]
|
||||
│ └── FACTURA → "01", BOLETA → "03",
|
||||
│ GUIA DE REMISION → "09", GUIA DE REMISION TRANSPORTISTA → "31"
|
||||
├── serie, numero = data["serie_numero"].split("-")
|
||||
│ └── numero = numero.lstrip("0") → elimina ceros a la izquierda
|
||||
│
|
||||
└── "{ruc}-{abrev}-{serie}-{numero}.pdf"
|
||||
ej: "20537929520-01-F002-486.pdf"
|
||||
|
||||
└── os.rename(origen, destino)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipos de documento detectados
|
||||
|
||||
| Tipo detectado | Código | Sección extraída |
|
||||
|----------------|--------|------------------|
|
||||
| FACTURA ELECTRÓNICA | 01 | Items, detracción, guías de remisión |
|
||||
| BOLETA DE VENTA | 03 | Solo datos base |
|
||||
| GUÍA DE REMISIÓN | 09 | Peso, destino, vehículo, items |
|
||||
| GUÍA DE REMISIÓN TRANSPORTISTA | 31 | Remitente, destinatario, vehículos, conductores |
|
||||
|
||||
---
|
||||
|
||||
## Salida
|
||||
|
||||
```python
|
||||
# pdf_cache → dict
|
||||
{
|
||||
"20537929520-01-F002-486.pdf": {
|
||||
"archivo": "20537929520-01-F002-486.pdf",
|
||||
"ruc_emisor": "20537929520",
|
||||
"tipo_documento": "FACTURA",
|
||||
"serie_numero": "F002-486",
|
||||
"fecha_emision": "08/07/2026",
|
||||
"ruc_receptor": "20123456789",
|
||||
"nombre_receptor": "EMPRESA SAC",
|
||||
"monto_total": "4680.00",
|
||||
"items": [
|
||||
{
|
||||
"cantidad": "350",
|
||||
"descripcion": "FLETE T001-127773 PESO: 23.26 TN GRR: T001-127773",
|
||||
"precio_unitario": "1.3396",
|
||||
"total": "468.86",
|
||||
"guias_remision": ["T001-127773"],
|
||||
"guia_transportista": None,
|
||||
"bolsas": None,
|
||||
"peso": "23.26",
|
||||
"unidad_peso": "TN"
|
||||
}
|
||||
],
|
||||
"detraccion": {"tiene": True, "porcentaje": "4.00", "monto": "187.20"}
|
||||
}
|
||||
}
|
||||
|
||||
# email["datos_pdf"] → list[dict] (misma estructura)
|
||||
```
|
||||
@@ -0,0 +1,257 @@
|
||||
# _procesar_xmls — Extracción y cruce de datos desde XML
|
||||
|
||||
## Ubicación
|
||||
|
||||
- **Archivo:** `pipeline.py` (línea 248-370)
|
||||
- **Librería:** `sunat_schema.py` (línea 1-520)
|
||||
|
||||
---
|
||||
|
||||
## Flujo general
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[_procesar_xmls] --> B[Iterar adjuntos del email]
|
||||
B --> C{Es .xml?}
|
||||
C -- No --> B
|
||||
C -- Sí --> D[extraer_datos_xml]:::highlight
|
||||
D --> E{Hay datos?}
|
||||
E -- No --> F[ℹ️ XML sin datos]
|
||||
F --> B
|
||||
E -- Sí --> G[Construir nombre PDF canónico]
|
||||
G --> H[Buscar PDF en cache o fallback]
|
||||
H --> I[Cruzar documentos_referencia desde PDF]:::highlight
|
||||
I --> J[Cruzar guías de remisión por línea]:::highlight
|
||||
J --> K[Agregar a lista de documentos]
|
||||
K --> B
|
||||
|
||||
B -- Fin --> L[return documentos]
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso a paso
|
||||
|
||||
```
|
||||
_procesar_xmls(pdf_cache) → list[dict]
|
||||
│
|
||||
├── carpeta = _ruta_carpeta(download_folder)
|
||||
│
|
||||
├── Por cada adjunto en email["adjuntos"]:
|
||||
│ │
|
||||
│ ├── ¿No termina en ".xml"? → continue
|
||||
│ │
|
||||
│ ├── xml_path = carpeta + adjunto
|
||||
│ │
|
||||
│ ├── sunat_schema.extraer_datos_xml(xml_path) → data | None
|
||||
│ │ └── Solo procesa Invoice tipo 01 y DespatchAdvice tipo 31
|
||||
│ │
|
||||
│ ├── ¿data es None? → ℹ️ XML sin datos, continue
|
||||
│ │
|
||||
│ ├── data["ruta_xml"] = xml_path
|
||||
│ │
|
||||
│ ├── Construir nombre PDF canónico:
|
||||
│ │ └── "{ruc_emisor}-{tipo}-{serie}-{numero}.pdf"
|
||||
│ │
|
||||
│ ├── Buscar PDF asociado:
|
||||
│ │ ├── 1. pdf_cache por nombre canónico
|
||||
│ │ └── 2. Fallback: mismo nombre con extensión .pdf
|
||||
│ │ └── data["ruta_pdf"] = ruta encontrada o None
|
||||
│ │
|
||||
│ ├── ¿data sin documentos_referencia?
|
||||
│ │ ├── Buscar en pdf_cache por nombre canónico
|
||||
│ │ │ └── pdf_utilities.extraer_referencias(items) → refs
|
||||
│ │ ├── Si no → fallback: buscar en email["datos_pdf"]
|
||||
│ │ └── Si encuentra → data["documentos_referencia"] = refs
|
||||
│ │
|
||||
│ ├── Asignar guías de remisión por línea:
|
||||
│ │ ├── refs_09 = [tipo 09] desde documentos_referencia
|
||||
│ │ ├── refs_31 = [tipo 31] desde documentos_referencia
|
||||
│ │ ├── pdf_items = pdf_cache[canonical].items[] (si existe)
|
||||
│ │ │
|
||||
│ │ └── Por cada línea en data["lineas"]:
|
||||
│ │ │
|
||||
│ │ ├── ¿Hay PDF items para esta línea?
|
||||
│ │ │ ├── Parsear guias_remision[] desde descripción PDF
|
||||
│ │ │ │ └── Regex: "{serie}-{numero}" con prefijo T/V/E
|
||||
│ │ │ └── Parsear guia_transportista desde descripción PDF
|
||||
│ │ │
|
||||
│ │ ├── ¿Hay refs_09 para esta línea?
|
||||
│ │ │ └── Asignar desde documentos_referencia ordenados
|
||||
│ │ │
|
||||
│ │ └── No → guias_remision = [], guia_transportista = None
|
||||
│ │
|
||||
│ └── ✅ Agregar data a documentos[]
|
||||
│
|
||||
└── return documentos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `extraer_datos_xml(xml_path)` `:highlight`
|
||||
|
||||
### Flujo de decisión
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[extraer_datos_xml] --> B[ET.parse xml]
|
||||
B --> C{Tag raíz?}
|
||||
|
||||
C -- Invoice --> D{tipo == 01?}
|
||||
D -- Sí --> E[_parse_invoice]:::highlight
|
||||
D -- No --> F[return None]
|
||||
|
||||
C -- DespatchAdvice --> G{tipo == 31?}
|
||||
G -- Sí --> H[_parse_despatch_advice]:::highlight
|
||||
G -- No --> F
|
||||
|
||||
C -- Otro --> F
|
||||
|
||||
E --> I[return datos.to_dict]
|
||||
H --> I
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
Solo procesa **Facturas tipo 01** y **Guías de Remisión Transportista tipo 31**.
|
||||
|
||||
---
|
||||
|
||||
### `_parse_invoice(root)` — Factura tipo 01
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[_parse_invoice] --> B[ID → serie, numero]
|
||||
B --> C[IssueDate → fecha_emision]
|
||||
C --> D[InvoiceTypeCode → tipo]
|
||||
D --> E[AccountingSupplierParty → ruc_emisor, razon_social]
|
||||
E --> F[AccountingCustomerParty → ruc_receptor, razon_social]
|
||||
F --> G[LegalMonetaryTotal → total_factura]
|
||||
G --> H[DespatchDocumentReference → documentos_referencia]
|
||||
H --> I{¿Hay refs?}
|
||||
I -- No --> J[Buscar en descripción de 1ra línea]
|
||||
I -- Sí --> K{Solo si tipo == 01}
|
||||
J --> K
|
||||
K --> L[PaymentTerms → detracción]
|
||||
L --> M[InvoiceLine → líneas de detalle]
|
||||
M --> N[return DatosComprobante]
|
||||
|
||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||
```
|
||||
|
||||
**Documentos de referencia** se obtienen de dos formas:
|
||||
|
||||
| Origen | Cómo |
|
||||
|--------|------|
|
||||
| `DespatchDocumentReference` | Tags XML estándar SUNAT (`cbc:ID`, `cbc:DocumentTypeCode`) |
|
||||
| Fallback en descripción | Si no hay refs, busca en `cbc:Description` de la primera línea con regex `{serie}-{numero}` |
|
||||
|
||||
**Detracción** se extrae de `PaymentTerms` con `ID = "Detraccion"`:
|
||||
|
||||
| Campo | Tag |
|
||||
|-------|-----|
|
||||
| `codigo_detraccion` | `cbc:PaymentMeansID` |
|
||||
| `porcentaje_detraccion` | `cbc:PaymentPercent` |
|
||||
| `monto_detraccion` | `cbc:Amount` |
|
||||
|
||||
**Líneas de detalle** (`InvoiceLine`):
|
||||
|
||||
| Campo | Tag |
|
||||
|-------|-----|
|
||||
| `descripcion` | `cac:Item/cbc:Description` |
|
||||
| `cantidad` | `cbc:InvoicedQuantity` |
|
||||
| `precio_unitario` | `cac:PricingReference/cac:AlternativeConditionPrice/cbc:PriceAmount` o `cac:Price/cbc:PriceAmount` |
|
||||
| `total_linea` | `cbc:LineExtensionAmount` |
|
||||
|
||||
---
|
||||
|
||||
### `_parse_despatch_advice(root)` — Guía tipo 31
|
||||
|
||||
| Campo | Tag |
|
||||
|-------|-----|
|
||||
| `ruc_emisor` | `DespatchSupplierParty/Party/PartyIdentification/ID` |
|
||||
| `razon_social_emisor` | `PartyName/Name` o `PartyLegalEntity/RegistrationName` |
|
||||
| `ruc_receptor` | `DeliveryCustomerParty/Party/PartyIdentification/ID` |
|
||||
| `razon_social_receptor` | `PartyLegalEntity/RegistrationName` |
|
||||
| `punto_partida` | `Delivery/Despatch/DespatchAddress/AddressLine/Line` |
|
||||
| `punto_llegada` | `Delivery/DeliveryAddress/AddressLine/Line` |
|
||||
| `peso_bruto` | `GrossWeightMeasure` |
|
||||
| `unidad_peso_bruto` | `GrossWeightMeasure` (atributo `unitCode`) |
|
||||
| `fecha_traslado` | `ShipmentStage/TransitPeriod/StartDate` |
|
||||
| `vehiculo_principal` | `TransportHandlingUnit/TransportEquipment/ID` |
|
||||
| `vehiculo_secundario` | `TransportHandlingUnit/TransportEquipment/AttachedTransportEquipment/ID` |
|
||||
| `licencia_conductor` | `ShipmentStage/DriverPerson/IdentityDocumentReference/ID` |
|
||||
| `nombre_conductor` | `ShipmentStage/DriverPerson/FirstName` |
|
||||
| `documento_conductor` | `ShipmentStage/DriverPerson/ID` |
|
||||
| `ubigeo_partida` | `Delivery/Despatch/DespatchAddress/ID` |
|
||||
| `lineas[]` | `DespatchLine` con `DeliveredQuantity` y `Item/Description` |
|
||||
|
||||
Documentos de referencia desde `AdditionalDocumentReference`.
|
||||
|
||||
---
|
||||
|
||||
## Cruce de referencias `:highlight`
|
||||
|
||||
### 1. Completar `documentos_referencia` desde PDF
|
||||
|
||||
Si el XML no trae documentos de referencia, se buscan en el PDF cache:
|
||||
|
||||
```
|
||||
pdf_cache[nombre_canonico].items[] → extraer_referencias()
|
||||
└── Por cada item:
|
||||
├── guias_remision[] → ref tipo 09
|
||||
└── guia_transportista → ref tipo 31
|
||||
```
|
||||
|
||||
### 2. Asignar guías por línea
|
||||
|
||||
Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea:
|
||||
|
||||
| Prioridad | Origen | Descripción |
|
||||
|-----------|--------|-------------|
|
||||
| 1ª | PDF items | Parse desde `items[i].guias_remision[]` y `items[i].guia_transportista` con regex `{T/V/E}{serie}-{numero}` |
|
||||
| 2ª | `documentos_referencia` | Asignación ordinal: `refs_09[i]`, `refs_31[i]` |
|
||||
| 3ª | Default | `[]` y `None` |
|
||||
|
||||
---
|
||||
|
||||
## Datos que produce
|
||||
|
||||
```python
|
||||
# documentos → list[dict]
|
||||
[
|
||||
{
|
||||
"ruc_emisor": "20537929520",
|
||||
"razon_social_emisor": "TRANSPORTES S.A.C.",
|
||||
"fecha_emision": "2026-07-08",
|
||||
"tipo": "01",
|
||||
"serie": "F002",
|
||||
"numero": "486",
|
||||
"ruc_receptor": "20123456789",
|
||||
"razon_social_receptor": "CLIENTE S.A.",
|
||||
"total_factura": 4680.0,
|
||||
"tiene_detraccion": True,
|
||||
"codigo_detraccion": "014",
|
||||
"porcentaje_detraccion": 4.0,
|
||||
"monto_detraccion": 187.20,
|
||||
"documentos_referencia": [
|
||||
{"tipo": "09", "serie": "T001", "numero": "127773"}
|
||||
],
|
||||
"lineas": [
|
||||
{
|
||||
"descripcion": "FLETE T001-127773",
|
||||
"cantidad": 350.0,
|
||||
"precio_unitario": 1.3396,
|
||||
"total_linea": 468.86,
|
||||
"guias_remision": [{"tipo": "09", "serie": "T001", "numero": "127773"}],
|
||||
"guia_transportista": None
|
||||
}
|
||||
],
|
||||
"ruta_xml": "/path/to/20537929520-01-F002-486.xml",
|
||||
"ruta_pdf": "/path/to/20537929520-01-F002-486.pdf",
|
||||
"tipo_descripcion": "Factura"
|
||||
}
|
||||
]
|
||||
```
|
||||
+121
@@ -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()
|
||||
|
||||
# ============================================================================
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user