diff --git a/database_utilities.py b/database_utilities.py index 666e795..180a507 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -111,4 +111,6 @@ def delete_email_by_id(email_id: str) -> bool: conn.close() return rows_affected > 0 -# ============================================================================ \ No newline at end of file +# ============================================================================ +if __name__ == "__main__": + delete_email_by_id("AAMkADljMzNhYmQwLTQyMjgtNDY2My05ZDUwLWEzNTBiNDZhZjFlMwBGAAAAAAB386nfKhkcTbYV6pPUJ245BwBdNWALkxgeSJ3BdBRukthBAAAAAAEMAABdNWALkxgeSJ3BdBRukthBAAAtOXesAAA=") \ No newline at end of file diff --git a/docs/inventario.md b/docs/inventario.md new file mode 100644 index 0000000..8cc9e07 --- /dev/null +++ b/docs/inventario.md @@ -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 | diff --git a/docs/main.md b/docs/main.md new file mode 100644 index 0000000..0dec9b5 --- /dev/null +++ b/docs/main.md @@ -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() +``` diff --git a/docs/pipeline.md b/docs/pipeline.md new file mode 100644 index 0000000..dffe07d --- /dev/null +++ b/docs/pipeline.md @@ -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 +``` diff --git a/docs/pipeline_completo.mkd b/docs/pipeline_completo.mkd deleted file mode 100644 index e750269..0000000 --- a/docs/pipeline_completo.mkd +++ /dev/null @@ -1,29 +0,0 @@ -# Pipeline Completo - -```mermaid -flowchart TD - A["📬 Correo recibido"] --> B{"1-2. BD check
¿Ya procesado?"} - B -->|"Sí"| C["⏭️ Saltar correo"] - B -->|"No"| D["3. Descargar adjuntos
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
extraer datos + renombrar"] - I --> J["7. Procesar XMLs
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
_validate_prices()"] - O --> P["11. Construir draft SAP
_build_sap_draft()"] - P --> Q["12. Guardar draft JSON"] - Q --> R{"13. ¿Precios coinciden?"} - R -->|Sí| S["Crear Draft en SAP B1
POST /Drafts"] - R -->|No| T["Notificar: validación falló"] - S --> U["14. Enviar email notificación"] - T --> U - N --> U -``` diff --git a/docs/procesar_pdfs.md b/docs/procesar_pdfs.md new file mode 100644 index 0000000..ea6363c --- /dev/null +++ b/docs/procesar_pdfs.md @@ -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) +``` diff --git a/docs/procesar_xmls.md b/docs/procesar_xmls.md new file mode 100644 index 0000000..8fa04e1 --- /dev/null +++ b/docs/procesar_xmls.md @@ -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" + } +] +``` diff --git a/hana_manager.py b/hana_manager.py new file mode 100644 index 0000000..a855630 --- /dev/null +++ b/hana_manager.py @@ -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() + +# ============================================================================ diff --git a/main.py b/main.py index b7fd931..e80eced 100644 --- a/main.py +++ b/main.py @@ -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.") # ============================================================================ diff --git a/outlook_utilities.py b/outlook_utilities.py index 08b8c3e..32e7104 100644 --- a/outlook_utilities.py +++ b/outlook_utilities.py @@ -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: diff --git a/pdf_utilities.py b/pdf_utilities.py index 808b23d..3b78357 100644 --- a/pdf_utilities.py +++ b/pdf_utilities.py @@ -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"(?= 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) diff --git a/pipeline.py b/pipeline.py index bf3a7a7..da81797 100644 --- a/pipeline.py +++ b/pipeline.py @@ -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}" ) - 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"09{g['serie']}-{g['numero']}{g['valor_sap']:.2f}" + for g in guias_valor_ref + ) + coincide = abs(valor_xml - total_sap) < 0.01 + icono = "✅" if coincide else "❌" + + return ( + "" + "" + f"{rows}" + f"" + f"" + f"" + "
Guía de RemisiónValor Referencial SAP
Total SAP{total_sap:.2f}
Valor Referencial XML{valor_xml:.2f}
{icono} {'Coincide' if coincide else 'No coincide'}
" + ) + + 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 ) # → "

✅ Draft SAP B1 creado exitosamente.

..." """ @@ -800,12 +1046,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde f"

Factura: {factura.get('serie')}-{factura.get('numero')}

" f"

Emisor: {factura.get('razon_social_emisor', '')}

" ) + valref_section = f"

Valor Referencial (XML vs SAP)

{valor_referencial_table}" if status == "created": - return f"

✅ Draft SAP B1 creado exitosamente.

DocEntry: {kwargs.get('doc_entry')}

{base}

Validación de precios

{validation_table}" + return f"

✅ Draft SAP B1 creado exitosamente.

DocEntry: {kwargs.get('doc_entry')}

{base}

Validación de precios

{validation_table}{valref_section}" elif status == "error": - return f"

❌ Error al crear Draft SAP B1.

{base}

Error: {kwargs.get('error')}

Validación de precios

{validation_table}" + return f"

❌ Error al crear Draft SAP B1.

{base}

Error: {kwargs.get('error')}

Validación de precios

{validation_table}{valref_section}" else: - return f"

⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.

{base}

Validación de precios

{validation_table}" + return f"

⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.

{base}

Validación de precios

{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, diff --git a/sapb1_manager.py b/sapb1_manager.py index 8f378ed..b732160 100644 --- a/sapb1_manager.py +++ b/sapb1_manager.py @@ -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) diff --git a/sunat_schema.py b/sunat_schema.py index 2190634..2ec5b96 100644 --- a/sunat_schema.py +++ b/sunat_schema.py @@ -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":