Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0ed6f519 | ||
|
|
e6cde62e72 |
No files matched your search
@@ -111,4 +111,6 @@ def delete_email_by_id(email_id: str) -> bool:
|
||||
conn.close()
|
||||
|
||||
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,48 +74,52 @@ def main() -> None:
|
||||
print("🚀 Demonio iniciado")
|
||||
print("=" * 60)
|
||||
|
||||
while True:
|
||||
try:
|
||||
while True:
|
||||
|
||||
inicio_ciclo = datetime.now()
|
||||
inicio_ciclo = datetime.now()
|
||||
|
||||
resultado = obtener_intervalo(inicio_ciclo)
|
||||
resultado = obtener_intervalo(inicio_ciclo)
|
||||
|
||||
if resultado is not None:
|
||||
if resultado is not None:
|
||||
|
||||
intervalo, modo = resultado
|
||||
intervalo, modo = resultado
|
||||
|
||||
print(
|
||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
|
||||
)
|
||||
|
||||
run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
|
||||
|
||||
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
||||
|
||||
if duracion > intervalo:
|
||||
print(
|
||||
f"⚠️ El ciclo demoró {duracion:.1f}s "
|
||||
f"(intervalo configurado {intervalo}s)"
|
||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
|
||||
)
|
||||
|
||||
dormir = max(0, intervalo - duracion)
|
||||
run_pipeline(dias, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
|
||||
|
||||
print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
|
||||
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
||||
|
||||
else:
|
||||
if duracion > intervalo:
|
||||
print(
|
||||
f"⚠️ El ciclo demoró {duracion:.1f}s "
|
||||
f"(intervalo configurado {intervalo}s)"
|
||||
)
|
||||
|
||||
dormir = segundos_hasta_proximo_inicio(inicio_ciclo)
|
||||
dormir = max(0, intervalo - duracion)
|
||||
|
||||
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
||||
print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
|
||||
|
||||
print(
|
||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
f"Fuera del horario.\n"
|
||||
f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
else:
|
||||
|
||||
time.sleep(dormir)
|
||||
dormir = segundos_hasta_proximo_inicio(inicio_ciclo)
|
||||
|
||||
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
||||
|
||||
print(
|
||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
f"Fuera del horario.\n"
|
||||
f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
|
||||
)
|
||||
|
||||
time.sleep(dormir)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n🛑 Demonio detenido por el usuario.")
|
||||
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+34
-20
@@ -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]:
|
||||
desc_line = desc_line.replace(amt, "", 1)
|
||||
seen = set()
|
||||
for amt in amounts:
|
||||
if amt not in seen:
|
||||
desc_line = desc_line.replace(amt, "", 1)
|
||||
seen.add(amt)
|
||||
desc_parts = [p.strip() for p in desc_line.split() if p.strip()]
|
||||
codigo = None
|
||||
for i in range(min(3, len(desc_parts))):
|
||||
if re.search(r"[A-Z].*\d|\d.*[A-Z]|[A-Z]{2,}-\d+", desc_parts[i]):
|
||||
codigo = desc_parts[i]
|
||||
desc_parts.pop(i)
|
||||
cod = None
|
||||
for j in range(min(3, len(desc_parts))):
|
||||
if re.search(r"[A-Z].*\d|\d.*[A-Z]|[A-Z]{2,}-\d+", desc_parts[j]):
|
||||
cod = desc_parts[j]
|
||||
desc_parts.pop(j)
|
||||
break
|
||||
if desc_parts and desc_parts[0].isdigit() and len(desc_parts[0]) <= 2:
|
||||
desc_parts.pop(0)
|
||||
descripcion = " ".join(desc_parts)
|
||||
current_item = {
|
||||
"codigo": codigo,
|
||||
"codigo": cod,
|
||||
"cantidad": qty,
|
||||
"descripcion": descripcion,
|
||||
"precio_unitario": p_unit.replace(",", ""),
|
||||
"total": total.replace(",", ""),
|
||||
"precio_unitario": p_unit,
|
||||
"total": total,
|
||||
"guias_remision": [],
|
||||
"guia_transportista": None,
|
||||
"bolsas": None,
|
||||
"peso": None,
|
||||
"unidad_peso": None,
|
||||
}
|
||||
if pre_item_buffer:
|
||||
current_item["descripcion"] = " ".join(pre_item_buffer) + " " + descripcion
|
||||
pre_item_buffer = []
|
||||
elif current_item and len(line) > 5:
|
||||
current_item["descripcion"] += " " + line
|
||||
elif not current_item and len(line) > 5:
|
||||
pre_item_buffer.append(line)
|
||||
if current_item: data["items"].append(current_item)
|
||||
|
||||
# Extraer guías de cada ítem usando los mismos patrones que sunat_schema
|
||||
GUIAS_PDF_PATTERNS = [
|
||||
(r"(?:GRE\s+REMITENTE|GRR|G\.R\.R)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "09"),
|
||||
(r"\b(?:GRT|G\.R\.T)\s*[:.]?\s*([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", "31"),
|
||||
]
|
||||
for item in data["items"]:
|
||||
desc = item["descripcion"]
|
||||
guias_remision_matches = re.findall(r"\b(?:GRR\w*|GR\b|G\.R\.R\.?|GUIA REMISI.N)\s*[:/\s]*((?:[TVE][A-Z0-9]{3})-\d+)", desc, re.IGNORECASE)
|
||||
item["guias_remision"] = [m.strip() for m in guias_remision_matches] if guias_remision_matches else []
|
||||
guia_transportista_match = re.search(r"\b(?:GRT\w*|GR\b|G\.R\.T\.?|GUIA TRANSPORTISTA)\s*[:/\s]*((?:[TVE][A-Z0-9]{3})-\d+)", desc, re.IGNORECASE)
|
||||
if guia_transportista_match:
|
||||
item["guia_transportista"] = guia_transportista_match.group(1).strip()
|
||||
for patron, tipo in GUIAS_PDF_PATTERNS:
|
||||
for m in re.finditer(patron, desc, re.IGNORECASE):
|
||||
numero = m.group(2).lstrip("0") or "0"
|
||||
if tipo == "09":
|
||||
item["guias_remision"].append(f"{m.group(1).upper()}-{numero}")
|
||||
else:
|
||||
item["guia_transportista"] = f"{m.group(1).upper()}-{numero}"
|
||||
bolsas_match = re.search(r"(\d+)\s*(?:BOLSAS|BLS|BOLS|SACOS)\b", desc, re.IGNORECASE)
|
||||
if bolsas_match:
|
||||
item["bolsas"] = bolsas_match.group(1)
|
||||
|
||||
+393
-128
@@ -20,14 +20,7 @@ import database_utilities
|
||||
import sunat_schema
|
||||
import sapb1_manager
|
||||
import pdf_utilities
|
||||
|
||||
CUADRO_VALOR_REFERENCIAL = {
|
||||
"CHINCHA": 89.09,
|
||||
"PISCO": 101.7,
|
||||
"CAÑETE": 79.81,
|
||||
"TARMA": 106.54,
|
||||
"SAN CLEMENTE": 94.27,
|
||||
}
|
||||
import hana_manager
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -104,6 +97,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
notificaciones por email.
|
||||
"""
|
||||
|
||||
def _log(msg: str, level: int = 1, last: bool = False) -> None:
|
||||
branch = " └── " if last else " ├── "
|
||||
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
|
||||
print(f"{pfx}{msg}")
|
||||
|
||||
def _descargar_adjuntos() -> list[str] | None:
|
||||
"""
|
||||
Descarga todos los adjuntos del correo usando Microsoft Graph.
|
||||
@@ -130,7 +128,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
return adjuntos
|
||||
return None
|
||||
except Exception as ex:
|
||||
print(f" ❌ Error descargando adjuntos: {ex}")
|
||||
_log(f"❌ Error descargando adjuntos: {ex}")
|
||||
return None
|
||||
|
||||
def _descomprimir() -> None:
|
||||
@@ -179,7 +177,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
ruta = os.path.join(carpeta, a)
|
||||
if os.path.exists(ruta):
|
||||
os.remove(ruta)
|
||||
print(f" ├── 🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
|
||||
_log("🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
|
||||
return False
|
||||
|
||||
for a in no_validos:
|
||||
@@ -236,9 +234,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
|
||||
canonical = os.path.basename(nuevo_path)
|
||||
pdf_cache[canonical] = data
|
||||
print(f" │ 📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})")
|
||||
_log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
|
||||
else:
|
||||
print(f" │ ⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}")
|
||||
_log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2)
|
||||
|
||||
if datos_pdf:
|
||||
email["datos_pdf"] = datos_pdf
|
||||
@@ -256,7 +254,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
3. Si el XML no trae ``documentos_referencia``, los completa desde
|
||||
el PDF cache (usando ``pdf_utilities.extraer_referencias``).
|
||||
4. Asigna las guías de remisión (``guias_remision[]``) y
|
||||
transportista (``guia_transportista``) a cada línea de detalle.
|
||||
transportista (``guia_transportista``) a cada línea de detalle desde
|
||||
``notes_text`` o ``documentos_referencia``.
|
||||
|
||||
Returns:
|
||||
list[dict] — Lista de documentos extraídos listos para
|
||||
@@ -277,26 +276,49 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
carpeta = _ruta_carpeta(download_folder)
|
||||
documentos = []
|
||||
|
||||
last_idx = len(email["adjuntos"]) - 1
|
||||
for idx, a in enumerate(email["adjuntos"]):
|
||||
prefijo = " │ └── " if idx == len(email["adjuntos"]) - 1 else " │ ├── "
|
||||
print(f"{prefijo}{a}")
|
||||
_log(a, 2, last=(idx == last_idx))
|
||||
|
||||
if not a.lower().endswith(".xml"):
|
||||
continue
|
||||
|
||||
xml_path = os.path.join(carpeta, a)
|
||||
print(f" │ 📄 Procesando XML: {a}")
|
||||
_log(f"📄 Procesando XML: {a}", 3)
|
||||
data = sunat_schema.extraer_datos_xml(xml_path)
|
||||
|
||||
if not data:
|
||||
print(f" │ ℹ️ XML sin datos (no es factura tipo 01 o guía 31)")
|
||||
_log("ℹ️ XML sin datos (no es factura tipo 01 o guía 31)", 3)
|
||||
continue
|
||||
|
||||
# CDR solo se renombra, no se procesa como documento
|
||||
if data.get("es_cdr"):
|
||||
prefijo = "CDR-"
|
||||
cdr_name = f"{prefijo}{data['ruc_emisor']}-{data['tipo']}-{data['serie']}-{data['numero']}.xml"
|
||||
cdr_dest = os.path.join(carpeta, cdr_name)
|
||||
if cdr_dest != xml_path:
|
||||
if os.path.exists(cdr_dest):
|
||||
os.remove(cdr_dest)
|
||||
os.rename(xml_path, cdr_dest)
|
||||
email["adjuntos"][idx] = cdr_name
|
||||
_log(f"ℹ️ {data.get('serie','?')}-{data.get('numero','?')} (CDR) — solo renombrado", 3)
|
||||
continue
|
||||
|
||||
data["ruta_xml"] = xml_path
|
||||
|
||||
# Renombrar XML a {prefijo}{ruc}-{tipo}-{serie}-{numero}.xml
|
||||
prefijo = ""
|
||||
xml_canonical = f"{prefijo}{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.xml"
|
||||
xml_destino = os.path.join(carpeta, xml_canonical)
|
||||
if xml_destino != xml_path:
|
||||
if os.path.exists(xml_destino):
|
||||
os.remove(xml_destino)
|
||||
os.rename(xml_path, xml_destino)
|
||||
email["adjuntos"][idx] = xml_canonical
|
||||
data["ruta_xml"] = xml_destino
|
||||
xml_path = xml_destino
|
||||
|
||||
# ruta_pdf: construir nombre canónico {ruc}-{tipo}-{serie}-{numero}.pdf
|
||||
# El número ya viene con lstrip("0") desde _split_serie_numero en sunat_schema
|
||||
# y generar_nombre_pdf ahora también aplica lstrip("0") para que coincidan
|
||||
pdf_canonical = f"{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.pdf"
|
||||
pdf_path = os.path.join(carpeta, pdf_canonical)
|
||||
if not os.path.exists(pdf_path):
|
||||
@@ -312,93 +334,257 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
|
||||
if refs:
|
||||
data["documentos_referencia"] = refs
|
||||
print(f" │ 🔄 documentos_referencia completados desde PDF ({len(refs)} refs)")
|
||||
_log(f"🔄 documentos_referencia completados desde PDF ({len(refs)} refs)", 3)
|
||||
if not data.get("documentos_referencia"):
|
||||
for pdf_data in email.get("datos_pdf", []):
|
||||
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
|
||||
if refs:
|
||||
data["documentos_referencia"] = refs
|
||||
print(f" │ 🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)")
|
||||
_log(f"🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)", 3)
|
||||
break
|
||||
|
||||
# Asignar guias_remision[] y guia_transportista a cada línea
|
||||
# Primero: todas las refs tipo 09 y 31 desde documentos_referencia
|
||||
# Asignar guias_remision[] y guia_transportista por línea
|
||||
# Prioridad: notes_text (ya parseado) > documentos_referencia
|
||||
refs_09 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "09"]
|
||||
refs_31 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "31"]
|
||||
# Segundo: si hay datos del PDF, usar agrupación por ítem (respeta multi-guía)
|
||||
pdf_items = (pdf_cache.get(pdf_canonical) or {}).get("items", [])
|
||||
for i, linea in enumerate(data.get("lineas", [])):
|
||||
if i < len(pdf_items):
|
||||
pi = pdf_items[i]
|
||||
linea["guias_remision"] = []
|
||||
for g_str in pi.get("guias_remision", []):
|
||||
m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", g_str)
|
||||
if m:
|
||||
linea["guias_remision"].append({
|
||||
"tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2),
|
||||
})
|
||||
else:
|
||||
partes = g_str.split("-", 1)
|
||||
if len(partes) == 2:
|
||||
linea["guias_remision"].append({
|
||||
"tipo": "09", "serie": partes[0].strip(), "numero": partes[1].strip(),
|
||||
})
|
||||
guia_transportista = pi.get("guia_transportista")
|
||||
if guia_transportista:
|
||||
m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", guia_transportista)
|
||||
if m:
|
||||
pi["guia_transportista_serie"] = m.group(1)
|
||||
pi["guia_transportista_numero"] = m.group(2)
|
||||
elif "-" in guia_transportista:
|
||||
partes_list = guia_transportista.split("-", 1)
|
||||
linea["guia_transportista"] = {"tipo": "31", "serie": partes_list[0].strip(), "numero": partes_list[1].strip()}
|
||||
else:
|
||||
linea["guia_transportista"] = None
|
||||
else:
|
||||
linea["guia_transportista"] = None
|
||||
elif i < len(refs_09):
|
||||
r = refs_09[i]
|
||||
linea["guias_remision"] = [{"tipo": "09", "serie": r["serie"].strip(), "numero": r["numero"].strip()}]
|
||||
linea["guia_transportista"] = refs_31[i] if i < len(refs_31) else None
|
||||
else:
|
||||
linea["guias_remision"] = []
|
||||
linea["guia_transportista"] = None
|
||||
|
||||
print(f" │ ✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})")
|
||||
for i, linea in enumerate(data.get("lineas", [])):
|
||||
gr = linea.get("guias_remision") or []
|
||||
gt = linea.get("guia_transportista")
|
||||
|
||||
if not gr and i < len(refs_09):
|
||||
es_ultima = i == len(data["lineas"]) - 1
|
||||
if es_ultima:
|
||||
gr = list(refs_09[i:])
|
||||
else:
|
||||
gr = [dict(refs_09[i])]
|
||||
if not gt and i < len(refs_31):
|
||||
gt = dict(refs_31[i])
|
||||
|
||||
linea["guias_remision"] = gr
|
||||
if gt:
|
||||
linea["guia_transportista"] = gt
|
||||
|
||||
_log(f"✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})", 3)
|
||||
documentos.append(data)
|
||||
|
||||
# Post-procesamiento: resolver GREs desde guías de transporte referenciadas
|
||||
guia_lookup = {}
|
||||
for doc in documentos:
|
||||
if doc.get("tipo") in ("31", "09"):
|
||||
guia_lookup[(doc["tipo"], doc["serie"], doc["numero"])] = doc
|
||||
|
||||
for doc in documentos:
|
||||
if doc.get("tipo") != "01":
|
||||
continue
|
||||
for ref in doc.get("documentos_referencia", []):
|
||||
if ref["tipo"] == "31":
|
||||
guia_doc = guia_lookup.get(("31", ref["serie"], ref["numero"]))
|
||||
if guia_doc:
|
||||
gre_list = [g for g in guia_doc.get("documentos_referencia", []) if g.get("tipo") == "09"]
|
||||
if gre_list:
|
||||
_log(f"🔗 Resueltas {len(gre_list)} GRE(s) desde guía {ref['serie']}-{ref['numero']}", 3)
|
||||
for i, linea in enumerate(doc.get("lineas", [])):
|
||||
if not linea.get("guias_remision"):
|
||||
es_ultima = i == len(doc["lineas"]) - 1
|
||||
if es_ultima:
|
||||
linea["guias_remision"] = gre_list[i:]
|
||||
elif i < len(gre_list):
|
||||
linea["guias_remision"] = [gre_list[i]]
|
||||
if not linea.get("guia_transportista"):
|
||||
linea["guia_transportista"] = dict(ref)
|
||||
|
||||
return documentos
|
||||
|
||||
def _guardar_resultado(documentos: list[dict]) -> None:
|
||||
def _completar_guias(documentos: list[dict], pdf_cache: dict) -> int:
|
||||
"""
|
||||
Guarda la lista de documentos extraídos en un archivo JSON.
|
||||
Completa guías faltantes en XML desde datos del PDF.
|
||||
|
||||
Para cada documento en ``documentos``, verifica si todas las líneas
|
||||
tienen ``guias_remision``. Si faltan, busca el PDF correspondiente en
|
||||
el cache y copia las guías de las líneas del PDF.
|
||||
|
||||
Args:
|
||||
documentos: Lista de documentos extraídos de XML.
|
||||
pdf_cache: Cache de datos PDF (canonical_name → datos).
|
||||
|
||||
Returns:
|
||||
int — Número de guías copiadas.
|
||||
"""
|
||||
total = 0
|
||||
for xd in documentos:
|
||||
has_all = all(
|
||||
len(linea.get("guias_remision") or []) > 0
|
||||
for linea in xd.get("lineas", [])
|
||||
)
|
||||
if has_all:
|
||||
continue
|
||||
|
||||
pdf_canonical = f"{xd.get('ruc_emisor', '')}-{xd.get('tipo', '')}-{xd.get('serie', '')}-{xd.get('numero', '')}.pdf"
|
||||
pd = pdf_cache.get(pdf_canonical)
|
||||
if not pd:
|
||||
continue
|
||||
|
||||
for i, xl in enumerate(xd.get("lineas", [])):
|
||||
if xl.get("guias_remision"):
|
||||
continue
|
||||
pl = pd["items"][i] if i < len(pd.get("items", [])) else {}
|
||||
p_gr = pl.get("guias_remision") or []
|
||||
if isinstance(p_gr[0], str) if p_gr else False:
|
||||
# Normalizar formato string → dict
|
||||
p_gr = [
|
||||
{"tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2).lstrip("0") or "0"}
|
||||
for g in p_gr
|
||||
for m in [re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)]
|
||||
if m
|
||||
]
|
||||
if p_gr:
|
||||
xl["guias_remision"] = list(p_gr)
|
||||
total += len(p_gr)
|
||||
if not xl.get("guia_transportista") and pl.get("guia_transportista"):
|
||||
gt = pl["guia_transportista"]
|
||||
if isinstance(gt, str):
|
||||
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
|
||||
if m:
|
||||
xl["guia_transportista"] = {
|
||||
"tipo": "31",
|
||||
"serie": m.group(1).upper(),
|
||||
"numero": m.group(2).lstrip("0") or "0",
|
||||
}
|
||||
elif isinstance(gt, dict):
|
||||
xl["guia_transportista"] = dict(gt)
|
||||
return total
|
||||
|
||||
def _validar_guias(documentos: list[dict]) -> None:
|
||||
for doc in documentos:
|
||||
for i, linea in enumerate(doc.get("lineas", [])):
|
||||
for j, g in enumerate(linea.get("guias_remision") or []):
|
||||
if not isinstance(g, dict) or not all(k in g and g[k] for k in ("tipo", "serie", "numero")):
|
||||
_log(f"⚠️ guias_remision[{j}] inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {g}", 2)
|
||||
gt = linea.get("guia_transportista")
|
||||
if gt and (not isinstance(gt, dict) or not all(k in gt and gt[k] for k in ("tipo", "serie", "numero"))):
|
||||
_log(f"⚠️ guia_transportista inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {gt}", 2)
|
||||
|
||||
def _guardar_resultado(documentos: list[dict], datos_pdf: list[dict]) -> list[str]:
|
||||
"""
|
||||
Guarda los resultados XML y PDF en archivos JSON separados.
|
||||
|
||||
El nombre del archivo sigue el patrón
|
||||
``resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
|
||||
``{tipo}_resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
|
||||
``output_folder``.
|
||||
|
||||
Args:
|
||||
documentos: Lista de diccionarios con datos extraídos de XML
|
||||
(producida por ``_procesar_xmls``).
|
||||
datos_pdf: Lista de diccionarios con datos extraídos de PDF
|
||||
(producida por ``_procesar_pdfs``).
|
||||
|
||||
Example:
|
||||
Si ``documentos[0]`` tiene ``serie="F002", numero="486"``::
|
||||
|
||||
_guardar_resultado(documentos)
|
||||
# → Crea resultado_F002-486_2026-07-08_12-00-00.json
|
||||
_guardar_resultado(documentos, pdf_datos)
|
||||
# → Crea xml_resultado_F002-486_2026-07-08_12-00-00.json
|
||||
# → Crea pdf_resultado_F002-486_2026-07-08_12-00-00.json
|
||||
"""
|
||||
if not documentos:
|
||||
return
|
||||
return []
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
serie = documentos[0].get("serie", "unknown")
|
||||
numero = documentos[0].get("numero", "unknown")
|
||||
filename = f"resultado_{serie}-{numero}_{ts}.json"
|
||||
filepath = os.path.join(output_folder, filename)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
base = f"{serie}-{numero}_{ts}"
|
||||
|
||||
saved: list[str] = []
|
||||
|
||||
# XML result
|
||||
xml_name = f"xml_resultado_{base}.json"
|
||||
xml_path = os.path.join(output_folder, xml_name)
|
||||
with open(xml_path, "w", encoding="utf-8") as f:
|
||||
json.dump(documentos, f, ensure_ascii=False, indent=4)
|
||||
print(f" 💾 Resultado guardado: {filename}")
|
||||
_log(f"💾 XML guardado: {xml_name}")
|
||||
saved.append(xml_path)
|
||||
|
||||
# PDF result (normalizado al mismo schema que XML)
|
||||
pdf_docs = []
|
||||
for raw in datos_pdf:
|
||||
if raw.get("error"):
|
||||
continue
|
||||
td = (raw.get("tipo_documento") or "").upper()
|
||||
tipo = pdf_utilities.TIPO_MAP.get(td)
|
||||
if not tipo:
|
||||
continue
|
||||
sn = raw.get("serie_numero") or ""
|
||||
serie_p, num_p = sunat_schema.split_serie_numero(sn)
|
||||
if not serie_p:
|
||||
continue
|
||||
doc = {
|
||||
"ruc_emisor": raw.get("ruc_emisor"),
|
||||
"razon_social_emisor": raw.get("nombre_receptor"),
|
||||
"fecha_emision": raw.get("fecha_emision"),
|
||||
"tipo": tipo,
|
||||
"serie": serie_p,
|
||||
"numero": num_p,
|
||||
"ruc_receptor": raw.get("ruc_receptor"),
|
||||
"razon_social_receptor": raw.get("nombre_receptor"),
|
||||
"total_factura": raw.get("monto_total"),
|
||||
"documentos_referencia": [],
|
||||
"tiene_detraccion": False,
|
||||
"codigo_detraccion": None,
|
||||
"porcentaje_detraccion": None,
|
||||
"monto_detraccion": None,
|
||||
"lineas": [],
|
||||
"ruta_pdf": raw.get("archivo"),
|
||||
}
|
||||
det = raw.get("detraccion") or {}
|
||||
if det.get("tiene"):
|
||||
doc["tiene_detraccion"] = True
|
||||
doc["porcentaje_detraccion"] = det.get("porcentaje")
|
||||
doc["monto_detraccion"] = det.get("monto")
|
||||
for item in raw.get("items") or []:
|
||||
linea = {
|
||||
"descripcion": item.get("descripcion", ""),
|
||||
"cantidad": item.get("cantidad"),
|
||||
"precio_unitario": item.get("precio_unitario"),
|
||||
"total_linea": item.get("total"),
|
||||
"guias_remision": [],
|
||||
"guia_transportista": None,
|
||||
}
|
||||
for g in item.get("guias_remision") or []:
|
||||
if isinstance(g, str):
|
||||
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)
|
||||
if m:
|
||||
linea["guias_remision"].append({
|
||||
"tipo": "09",
|
||||
"serie": m.group(1).upper(),
|
||||
"numero": m.group(2).lstrip("0") or "0",
|
||||
})
|
||||
elif isinstance(g, dict):
|
||||
linea["guias_remision"].append(dict(g))
|
||||
gt = item.get("guia_transportista")
|
||||
if gt:
|
||||
if isinstance(gt, str):
|
||||
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
|
||||
if m:
|
||||
linea["guia_transportista"] = {
|
||||
"tipo": "31",
|
||||
"serie": m.group(1).upper(),
|
||||
"numero": m.group(2).lstrip("0") or "0",
|
||||
}
|
||||
elif isinstance(gt, dict):
|
||||
linea["guia_transportista"] = dict(gt)
|
||||
doc["lineas"].append(linea)
|
||||
pdf_docs.append(doc)
|
||||
|
||||
if pdf_docs:
|
||||
pdf_name = f"pdf_resultado_{base}.json"
|
||||
pdf_path = os.path.join(output_folder, pdf_name)
|
||||
with open(pdf_path, "w", encoding="utf-8") as f:
|
||||
json.dump(pdf_docs, f, ensure_ascii=False, indent=4)
|
||||
_log(f"💾 PDF guardado: {pdf_name}")
|
||||
saved.append(pdf_path)
|
||||
|
||||
return saved
|
||||
|
||||
def _build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
|
||||
"""
|
||||
@@ -420,12 +606,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
- ``WarehouseCode``, ``U_IDC_GRFLETE`` y campos fijos.
|
||||
Cuando la validación es ``ok=True`` usa los valores de SAP
|
||||
(``draft_quantity``, ``draft_price``); cuando no, usa los del XML.
|
||||
5. Calcula ``U_IDC_VALREF`` como:
|
||||
``total_weight × CUADRO_VALOR_REFERENCIAL[zona]``
|
||||
donde ``total_weight`` es la suma de ``Weight1`` de todos los
|
||||
DeliveryNotes de la guía, y ``zona`` se obtiene del campo ``U_IDC_DESTINO`` del primer
|
||||
DeliveryNote de la primera guía, mapeado contra el maestro de
|
||||
zonas SAP (``U_IDC_ZONAS``).
|
||||
5. Calcula ``U_IDC_VALREF`` como la suma de ``valor_referencial``
|
||||
(extraído del XML SUNAT por ``sunat_schema``) de todas las
|
||||
líneas de la factura.
|
||||
|
||||
Args:
|
||||
factura: Diccionario con los datos de la factura extraídos del
|
||||
@@ -475,11 +658,14 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
emision = datetime.strptime(fecha_emision, "%Y-%m-%d")
|
||||
item_code = "C103002-A" if (emision.year, emision.month) < (datetime.now().year, datetime.now().month) else "C103002"
|
||||
|
||||
neto = round(total - monto_detraccion, 2)
|
||||
|
||||
if tiene_detraccion:
|
||||
# neto = round(total - monto_detraccion, 2)
|
||||
monto_detraccion = math.ceil(monto_detraccion)
|
||||
neto = total - monto_detraccion
|
||||
installments = [
|
||||
{"DueDate": fecha_emision, "Total": round(monto_detraccion, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
|
||||
{"DueDate": fecha_emision, "Total": neto, "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
||||
{"DueDate": fecha_emision, "Total": monto_detraccion, "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
|
||||
{"DueDate": fecha_emision, "Total": round(neto,2), "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
||||
]
|
||||
else:
|
||||
installments = [
|
||||
@@ -487,8 +673,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
]
|
||||
|
||||
doc_lines = []
|
||||
total_weight = 0
|
||||
zona_guia = ""
|
||||
guia_referencia = ""
|
||||
|
||||
for i, linea in enumerate(factura.get("lineas", [])):
|
||||
@@ -497,18 +681,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
if not guias_lt:
|
||||
continue
|
||||
|
||||
|
||||
zona_guia = guias_lt[0].get("zona","")
|
||||
|
||||
guia_referencia = f"{guias_lt[0]['guia']['tipo']}{guias_lt[0]['guia']['serie']}-{guias_lt[0]['guia']['numero']}"
|
||||
|
||||
|
||||
for item in guias_lt:
|
||||
codigo_guia = f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}"
|
||||
lt_val = math.trunc(item["draft_price"] * 1_000_000) / 1_000_000
|
||||
qty = item.get("draft_quantity", int(item.get("quantity", 1)))
|
||||
|
||||
total_weight += item.get("weight1", 0)
|
||||
|
||||
doc_lines.append({
|
||||
"ItemCode": item_code,
|
||||
#"ItemDescription": linea.get("descripcion", "TRANSPORTE DE CARGA MERCADERIA")[:200],
|
||||
@@ -529,7 +708,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
})
|
||||
|
||||
|
||||
valor_referencial = round((total_weight / 1000 )* (CUADRO_VALOR_REFERENCIAL.get(zona_guia, 0)),2)
|
||||
valor_referencial = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
|
||||
|
||||
return {
|
||||
"DocType": "dDocument_Items",
|
||||
@@ -544,6 +723,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
"JournalMemo": razon_social_emisor[:254],
|
||||
"DocObjectCode": "oPurchaseInvoices",
|
||||
"U_BPV_SERI": f"{serie}-01",
|
||||
"U_VS_NATNUM": "T",
|
||||
"U_BPV_NCON2": numero,
|
||||
"U_BPP_MDTD": tipo,
|
||||
"U_BPP_MDBI": "A",
|
||||
@@ -649,14 +829,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
for guia in guias:
|
||||
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
|
||||
try:
|
||||
# consulta a sap por cada guia de remision
|
||||
# esto nos puede retornar 0, 1 o mas documentos
|
||||
# lo ideal es que nos retorne 0 o 1 documento
|
||||
delivery_notes = sapb1_manager.get_delivery_notes(
|
||||
tipo=guia["serie"], serie=guia["tipo"], numero=guia["numero"]
|
||||
)
|
||||
# cantidad de documentos encontrados en sap
|
||||
print(f" │ 🔍 Guía {codigo_guia}: {len(delivery_notes)} documento(s) encontrado(s) en SAP")
|
||||
|
||||
wh_set = set()
|
||||
suma_line_total = 0.0
|
||||
@@ -665,7 +840,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
weight1 = 0.0
|
||||
zona = ""
|
||||
|
||||
# recorremos cada documento encontrado
|
||||
for dn in delivery_notes:
|
||||
# obtenemos la zona de destino del documento
|
||||
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
|
||||
@@ -697,17 +871,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
max_qty = max(max_qty, quantity)
|
||||
|
||||
sum_pav += price_after_vat
|
||||
print(f" │ └─ {dn.get('Series', '?')}-{dn.get('DocNum', '?')} | ItemCode: {linea_detalle_dict.get('ItemCode', '')} | PriceAfterVAT: {price_after_vat:.6f} | Quantity: {quantity:.6f} | LineTotal: {line_total:.6f}")
|
||||
_log(f"└─ {dn.get('Series', '?')}-{dn.get('DocNum', '?')} | ItemCode: {linea_detalle_dict.get('ItemCode', '')} | PriceAfterVAT: {price_after_vat:.6f} | Quantity: {quantity:.6f} | LineTotal: {line_total:.6f}", 4)
|
||||
|
||||
guias_lt.append({"guia": guia, "line_total": suma_line_total, "warehouse_code": ", ".join(sorted(wh_set)) if wh_set else "A-CENTRA", "quantity": max_qty, "price_after_vat_sum": sum_pav, "zona":zona, "weight1": weight1, "draft_quantity": 0, "draft_price": 0.0})
|
||||
all_wh.update(wh_set)
|
||||
except Exception as ex:
|
||||
print(f" │ ⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}")
|
||||
_log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2)
|
||||
guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0})
|
||||
all_ok = False
|
||||
|
||||
sap_total = sum(item["line_total"] for item in guias_lt)
|
||||
sin_sap = all(item["line_total"] == 0.0 and item["quantity"] == 0.0 for item in guias_lt)
|
||||
ok = all_ok and abs(total_linea - sap_total) < 0.01
|
||||
label = "Sin guías SAP" if sin_sap else "LineTotal (FL*) vs total_linea"
|
||||
grflete_str = "; ".join(f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}" for item in guias_lt)
|
||||
wh_str = ", ".join(sorted(all_wh)) if all_wh else "A-CENTRA"
|
||||
|
||||
@@ -724,16 +900,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
"ok": ok,
|
||||
"draft_price": total_linea,
|
||||
"sap_price": sap_total,
|
||||
"label": "LineTotal (FL*) vs total_linea",
|
||||
"label": label,
|
||||
"warehouse_code": wh_str,
|
||||
"guias_lt": guias_lt,
|
||||
})
|
||||
|
||||
print(f" │ Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}")
|
||||
if ok:
|
||||
print(f" │ ✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}")
|
||||
if sin_sap:
|
||||
_log("⏭️ Sin guías en SAP — línea saltada", 3)
|
||||
else:
|
||||
print(f" │ ❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}")
|
||||
_log(f"Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}", 3)
|
||||
if ok:
|
||||
_log(f"✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}", 3)
|
||||
else:
|
||||
_log(f"❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}", 3)
|
||||
|
||||
todas_coinciden = all(r["ok"] for r in resultados)
|
||||
return todas_coinciden, resultados
|
||||
@@ -768,7 +947,72 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
||||
f"{rows}</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,
|
||||
|
||||
+81
-12
@@ -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:
|
||||
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
|
||||
"""
|
||||
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,
|
||||
"$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)
|
||||
|
||||
+217
-16
@@ -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'
|
||||
m = re.search(patron, texto)
|
||||
if m:
|
||||
return m.group(1).upper(), m.group(2)
|
||||
for patron in SERIE_NUMERO_PATTERNS:
|
||||
m = re.search(patron, texto)
|
||||
if m:
|
||||
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":
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user