26 Commits
Author SHA1 Message Date
GeorgeandClaude Sonnet 5.5 3b5400018c versión 1.2.1: la diferencia de importes con SAP se redondea a 2 decimales
La comparación abs(a - b) < 0.01 no era fiable por la coma flotante: el mismo
céntimo se aceptaba o rechazaba según el importe (431.41 contra 431.40 se
rechazaba y 624.39 contra 624.38 se aceptaba). pipeline_utils.importes_coinciden
redondea la diferencia a 2 decimales, lo que muestra un comprobante, y admite
hasta 1 céntimo. Se usa en la validación de precios y en la comparación del
valor referencial. Con datos reales de SAP (49 líneas) ningún resultado cambió.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:07:28 -05:00
GeorgeandClaude Sonnet 5.5 cdcecc2b56 versión 1.2.0: despliegue como servicio de Windows con NSSM
- deploy/: scripts de PowerShell para instalar el servicio (cuenta de servicio
  con acceso a la ruta de red, reinicio automático, parada con Ctrl+C,
  rotación de la salida, PYTHONUTF8), actualizar a una versión etiquetada o
  volver a una anterior, y desinstalar.
- docs/despliegue_windows.md: requisitos, instalación, operación, rollback y
  problemas frecuentes.
- tools/verificar_conexiones.py: comprueba .env, Graph, SAP, HANA y la ruta de
  red sin modificar nada.
- CHANGELOG.md, README e inventario; pyproject.toml pasa a 1.2.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 f01f21b1c3 se completan los horarios, se fijan las rutas al código y se muestra la versión
- HORARIOS cubre las 24 horas: se agregan 21:30-24:00 (cada 60 min) y
  05:00-06:30 (cada 30 min); antes el demonio dormía hasta las 06:30 en esos
  tramos. Se corrigen los comentarios, que no coincidían con los valores.
  tests/test_horarios.py garantiza que no haya huecos ni solapes.
- La carpeta de resultados se resuelve junto al código, no relativa al cwd
  (un servicio de Windows puede arrancar desde cualquier carpeta).
- copiar_a_red no crea carpetas locales cuando RUTA_RED_FACTURACION no es una
  ruta absoluta en el sistema operativo (una UNC fuera de Windows); antes el
  draft podía referenciar archivos que no estaban en el recurso compartido.
- El log muestra la versión del proyecto (pyproject.toml) al arrancar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 f0ad6aa78e se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea
- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo,
  serie, número) en facturas de compra no anuladas y en drafts; si existe no
  se crea otro draft y se responde "ya está registrada" sin pisar el registro
  de una aceptación anterior.
- SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout,
  sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída
  de Service Layer con HANA sin resultados ya no se lee como "guía no existe".
- Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla
  "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin
  repetir avisos; al agotarse se avisa al equipo.
- La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un
  correo no se repite al reintentarlo.
- seguimiento: columna intentos (con migración), registrar_intento y el
  estado de notificación en_espera.
- database_utilities.DB_NAME pasa a ser absoluto (junto al código).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 4deb9d4e4d se responde al remitente con las plantillas de aceptado/rechazado
El sistema contesta a quien envió el correo con la plantilla que corresponda.
Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el
asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta
al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo.

- respuesta_proveedor.py: contextos de las plantillas, envío y protecciones
  (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes
  automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h).
- observaciones.py: motivos de rechazo con título, filas y cómo corregir
  (borrador de textos); también para correos sin comprobante válido.
- Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y
  ya no tumba el ciclo; "guía no encontrada" exige que HANA responda.
- outlook_utilities: es_mensaje_automatico y copia oculta en send_email.
- seguimiento: notificacion_destinatario (con migración) y
  contar_respuestas_correo.
- config_validator anuncia el modo activo; documentación y pruebas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:55:54 -05:00
GeorgeandClaude Sonnet 5.5 af9e3961e5 se documenta cómo obtener CLIENT_ID, TENANT_ID y CLIENT_SECRET de Azure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:55:54 -05:00
GeorgeandClaude Sonnet 5.5 53f203e832 se agregan las plantillas de aceptado/rechazado y su renderizador
plantilla/ con los dos HTML de correo y ejemplos.json (6 escenarios ficticios);
plantillas.py renderiza el subconjunto de Mustache que usan (variables con
escape de HTML, secciones e invertidas), en modo estricto: un dato que falta
da error en vez de un correo con huecos. tools/previsualizar_plantillas.py
genera las vistas previas y envía correos de prueba (--enviar). En la
plantilla de rechazo el RUC, la fila Comprobante y la tabla de guías son
opcionales, para el rechazo de un correo sin comprobante.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:55:54 -05:00
GeorgeandClaude Sonnet 5.5 96dca78162 se integra en draft_sap.py el cambio ee64e5f: DueDate de cuotas = fecha_vencimiento
El commit ee64e5f del remoto cambió el DueDate de las cuotas en pipeline.py,
que ya se había dividido en módulos; al hacer el rebase se tomó la versión
dividida y aquí se reaplica el mismo cambio en draft_sap.build_sap_draft
(también se quita la línea comentada de ItemDescription). Se actualizan las
pruebas y la nota de construir_draft.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:04:11 -05:00
GeorgeandClaude Sonnet 5.5 297cfde0e1 fase 1: registro de comprobantes con código de seguimiento
Nueva tabla comprobantes (creada en init_db) y módulo seguimiento.py: cada
comprobante recibe un código ABR-AAMMDD-NNNNN que conserva si reaparece en
otro correo, más su resultado (aceptado/rechazado/error) y el estado de la
notificación. El pipeline lo registra por factura sin bloquear el flujo si el
seguimiento falla. Las pruebas usan una BD temporal para no tocar emails.db.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 9d295d6485 se reorganiza tests/ y se agrega suite pytest
Los scripts manuales pasan a tools/ y los correos de ejemplo a
tests/fixtures/. Suite de 95 pruebas: caracterización de la extracción sobre
los 46 correos (resultados esperados en tests/golden/, fuera de la carpeta
que descargar_correos_prueba borra), unitarias de draft_sap, notificaciones,
documentos, adjuntos y config_validator, y flujo completo de
_procesar_un_correo con SAP/HANA/Outlook simulados. pytest como dependencia
dev y configuración en pyproject.toml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 d4c1e0a264 se agrega README y se completa y corrige la documentación
README en la raíz con instalación, uso, estructura e índice de docs. Nuevos
docs/hana_manager.md y docs/outlook_utilities.md. Se renombran los .mkd a .md
y se corrigen construir_draft y validar_precios, que describían lógica ya
inexistente (CUADRO_VALOR_REFERENCIAL, use_sap_prices). Se actualizan los
docstrings obsoletos y las cifras de líneas del inventario.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 767558cbfe se valida la configuración (.env) al arrancar
config_validator.verificar_configuracion termina con código 1 si faltan
variables obligatorias (pipeline, Graph, SAP B1, HANA), si hay ruta de red
sin RUC_EMPRESA, y advierte por SSL desactivado, ruta de red ausente o
LOG_LEVEL inválido. Se invoca desde main.py y tools/consulta_rapida.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 3fa26e3ef6 se migran los print de los módulos a logging con nivel explícito
pipeline_utils.log pasa a usar logging (parámetro severity) y los print de
pipeline, sapb1_manager, hana_manager, outlook_utilities, email_utilities y
main pasan a logger.info/warning/error, de modo que LOG_LEVEL filtre por
severidad. El texto del log no cambia. Se mantienen los print de CLI en
sunat_schema (__main__), tools/ y tests/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 5ef9c90fc0 se mueve consulta_rapida.py a tools/ y se corrige ruta de ids_prueba.json
El script apuntaba a test/ids_prueba.json, carpeta que ya no existe tras el
renombre a tools/. Ahora resuelve rutas desde su ubicación y agrega la raíz
del proyecto al sys.path. Se quita de py-modules y se actualiza inventario.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 71b01a2130 se alinean pyproject.toml y requirements.txt y se eliminan imports sin usar
Dependencias directas con versiones fijas (incluye hdbcli, se quita el alias
dotenv y las transitivas), build-backend corregido a setuptools.build_meta y
py-modules declarados para el layout plano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
GeorgeandClaude Sonnet 5.5 7d2f3212c0 se divide pipeline.py en módulos (adjuntos, documentos, persistencia, draft_sap, notificaciones)
Las funciones anidadas de _procesar_un_correo pasan a funciones de módulo
con parámetros explícitos; pipeline.py queda solo como orquestador.
copiar_a_red lee las variables de entorno al ejecutarse. Se actualizan docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:03:38 -05:00
georgeandClaude Sonnet 5 ee64e5f773 se usa fecha_vencimiento en DueDate de las cuotas del draft
Las cuotas (DocumentInstallments) ahora usan la misma fecha_vencimiento
calculada para DocDueDate, en vez de fecha_emision del XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 09:32:11 -05:00
georgeandClaude Sonnet 5 c532170ac2 se agrega fallback OWTR y calculo de fecha de vencimiento por condicion de pago
- hana_manager.get_delivery ahora busca en ODLN y, si no encuentra, en OWTR
- sapb1_manager resuelve StockTransfers -> Orders (via U_IDC_NUMOV) para
  validar precios de flete cuando la guia es un traslado de inventario
- se corrige el orden de parametros tipo/serie/numero (sin cruces) en
  get_delivery, get_delivery_notes y _get_delivery_entries
- se agrega sapb1_manager.obtener_condicion_pago (BusinessPartners +
  PaymentTermsTypes) y pipeline._calcular_fecha_vencimiento para que
  DocDueDate del draft sea fecha de contabilizacion + dias de la condicion
  de pago del proveedor
- se agrega variante de regex para "VALOR REFER" truncado en sunat_schema.py
- se actualiza documentacion (docs/) reflejando estos cambios

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:21:04 -05:00
GeorgeandClaude Sonnet 5 d023f14ad9 se renombra carpeta test a tools
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 07:41:39 -05:00
GeorgeandClaude Sonnet 5 911bd2da34 se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 07:40:26 -05:00
George 3c2bb2b714 se agrega manejo de ejecución por horarios 2026-07-13 14:20:57 -05:00
George 0f17284020 se agregó comentarios en el proceso de pipeline.py 2026-07-13 12:24:59 -05:00
George 603243b7e0 ahora se procesa de inicio a fin correo por correo 2026-07-09 09:48:30 -05:00
George 8cdd075d15 se agrego el modulo sunat_schema.py que lee el archivo xml y extrae la información 2026-06-03 18:32:18 -05:00
George a29a44e361 se termina la descarga de los adjuntos y solo se deja los mails con adjuntos pdf y xml 2026-06-01 18:05:44 -05:00
george c08554e775 primer commit 2026-06-01 16:31:24 -05:00
324 changed files with 35449 additions and 1628 deletions

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# Copiar este archivo como ".env" (en la misma carpeta que main.py) y completar
# con los valores reales. ".env" nunca debe subirse a git (ya está en .gitignore).
# --- Correo / carpetas del pipeline ---
EMAIL = correo@dominio.com
DOWNLOAD_FOLDER = adjuntos
OUTPUT_FOLDER = resultado
# --- Ruta de red de Facturación Electrónica (ver pipeline._copiar_a_red) ---
RUTA_RED_FACTURACION = \\servidor\ruta\a\Facturacion Electronica\PRODUCCION
RUC_EMPRESA = 00000000000
# --- SAP Business One Service Layer ---
SAP_B1_URL = https://servidor:50000/b1s/v1
SAP_B1_COMPANY =
SAP_B1_USER =
SAP_B1_PASSWORD =
SAP_B1_VERIFY_SSL = false
SAP_B1_TIMEOUT = 15
# --- SAP HANA (fallback de consultas, ver hana_manager.py) ---
HANA_SERVER =
HANA_PORT = 30015
HANA_USER =
HANA_PASSWORD =
HANA_SCHEMA =
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
# Cómo obtener estos valores: docs/configurar_msal.md
CLIENT_ID =
CLIENT_SECRET =
TENANT_ID =
VERIFY_SSL = false
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
LOG_LEVEL = INFO
# --- Respuesta al proveedor (aceptado / rechazado), ver docs/respuesta_proveedor.md ---
# prueba (por defecto): la respuesta va al equipo con asunto "[PRUEBA → remitente]", NO al proveedor.
# real: la respuesta va a quien envió el correo, con copia oculta al equipo.
NOTIFICACION_MODO = prueba
# Direcciones internas (coma o punto y coma): reciben el correo técnico y las respuestas en modo prueba.
NOTIFICACION_EQUIPO = equipo1@dominio.com,equipo2@dominio.com
# Contacto del botón "Escribir a Aberio" (por defecto, el buzón EMAIL).
NOTIFICACION_CONTACTO =
# URL pública (https) del logo que se muestra en el correo. Vacío = sin logo.
LOGO_URL =
+4
View File
@@ -10,4 +10,8 @@ adjuntos/
resultado/
emails.db
.env.dev
.pytest_cache/
*.egg-info/
build/
procesos/
tools/previews/
+48
View File
@@ -0,0 +1,48 @@
# Historial de cambios
Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como etiquetas de git (`v1.2.1`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar.
## [1.2.1] — 2026-10-22
### Corregido
- **Tolerancia de importes:** la diferencia entre el importe de la factura y el de SAP se redondea a 2 decimales (lo que muestra un comprobante) y se admite hasta 1 céntimo (`pipeline_utils.importes_coinciden`). Antes se comparaba `abs(a - b) < 0.01` sin redondear, y por la coma flotante el mismo céntimo se aceptaba o se rechazaba según el importe: por ejemplo 431.41 contra 431.40 se rechazaba y 624.39 contra 624.38 se aceptaba. Con datos reales de SAP, ninguna línea cambió de resultado, así que no se acepta nada que antes se rechazara con razón. También se aplica a la comparación del valor referencial del correo técnico.
## [1.2.0] — 2026-10-22
### Agregado
- **Respuesta al proveedor** con las plantillas HTML de aceptado y rechazado (`plantilla/`), enviada a quien envió el correo. Ver `docs/respuesta_proveedor.md`.
- `NOTIFICACION_MODO`: `prueba` (por defecto; la respuesta va al equipo, no al proveedor) o `real` (al remitente, con copia oculta al equipo).
- Protecciones: no responde a remitentes desconocidos o sin dominio válido, al propio buzón, a `no-reply`, a mensajes automáticos ni rebotes, a respuestas de nuestras notificaciones, y limita a 3 respuestas por remitente cada 24 h en correos sin comprobante.
- Rechazo con observaciones estructuradas (título, detalle, cómo corregir) también para correos sin comprobante válido.
- **Se valida si la factura ya existe en SAP** (factura de compra no anulada o draft) antes de crear el draft; si existe, no se duplica y se responde que ya está registrada.
- **Servicio fuera de línea:** si SAP Service Layer o HANA no responden, se avisa una vez al proveedor ("en cola, no necesita reenviar") y el correo se reintenta en los ciclos siguientes, hasta 24 veces. Plantilla `plantilla-fuera-de-linea.html`.
- **Código de seguimiento** (`ABR-AAMMDD-NNNNN`) por comprobante, con su resultado y el estado de la notificación (`seguimiento.py`, tabla `comprobantes`).
- **Despliegue en Windows como servicio con NSSM** (`deploy/`, `docs/despliegue_windows.md`).
- Validación del `.env` al arrancar (`config_validator.py`), que además anuncia el modo de notificación.
- Versión del proyecto en el log al arrancar y `tools/verificar_conexiones.py` (comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red sin modificar nada).
- Suite de pruebas pytest (más de 280) y herramientas de vista previa y envío de prueba de las plantillas (`tools/previsualizar_plantillas.py`).
- Documentación: `README.md`, guía de MSAL (`docs/configurar_msal.md`) y una página por módulo principal.
### Cambiado
- El correo técnico (tablas de SAP y JSON adjuntos) va **solo al equipo**; antes iba también al remitente.
- Un error de SAP/HANA o un fallo inesperado ya no se le comunica al proveedor como rechazo y ya no interrumpe el ciclo: solo esa factura.
- "Guía no encontrada" exige que HANA responda: si no, se trata como servicio fuera de línea.
- `DueDate` de las cuotas del draft = fecha de vencimiento (antes, fecha de emisión).
- **Horarios sin huecos:** se agregan 21:30–24:00 (cada 60 min) y 05:00–06:30 (cada 30 min); antes el demonio dormía hasta las 06:30 en esos tramos.
- La base `emails.db` y la carpeta de resultados se resuelven junto al código, no relativas al directorio de trabajo.
- `copiar_a_red` ya no crea carpetas locales cuando `RUTA_RED_FACTURACION` no es una ruta absoluta en el sistema operativo (una ruta UNC fuera de Windows); antes el draft podía referenciar archivos que no estaban en el recurso compartido.
- `pipeline.py` se divide en módulos (`adjuntos`, `documentos`, `persistencia`, `draft_sap`, `notificaciones`).
- Los `print` de los módulos pasan a `logging` con nivel explícito (`LOG_LEVEL` filtra por severidad).
- `pyproject.toml` y `requirements.txt` alineados, con las dependencias directas fijadas.
- `consulta_rapida.py` y los scripts manuales pasan a `tools/`; los correos de ejemplo, a `tests/fixtures/`.
### Eliminado
- `tools/documento.json` (token de ngrok sin uso). **El token sigue en el historial de git: revóquelo.**
### Seguridad
- Pendiente de rotar: la contraseña de SAP y el token de ngrok, presentes en el historial de git (`tools/aberio.http`, `tools/documento.json`).
## [1.1.0]
Versión anterior a este registro: descarga de adjuntos, extracción de XML y PDF, validación de precios contra SAP B1, creación del draft y notificación por correo.
+106
View File
@@ -0,0 +1,106 @@
# descarga_factura_transporte
Demonio que descarga las facturas de transporte (PDF/XML SUNAT) recibidas en un buzón de Office 365, valida sus precios contra SAP Business One y crea el borrador (Draft) de la factura de compra. Al terminar, notifica el resultado por correo al remitente.
## Qué hace con cada correo
```
Bandeja de entrada (Microsoft Graph)
→ descarga adjuntos, descomprime ZIP, se queda con PDF/XML
→ extrae datos de PDF y XML y asigna las guías de remisión por línea
→ comprueba que la factura no exista ya en SAP
→ valida precios de cada guía contra SAP (Service Layer, con fallback a HANA)
→ crea el Draft en SAP B1 si todos los precios coinciden
→ si SAP no responde, avisa "en cola" y reintenta en los ciclos siguientes
→ responde a quien envió el correo con la plantilla de aceptado o rechazado
(por defecto en modo prueba: la respuesta va al equipo; ver docs/respuesta_proveedor.md)
```
Cada correo se registra en una base SQLite (`emails.db`) y no se vuelve a procesar. Detalle del flujo en [`docs/pipeline.md`](docs/pipeline.md).
## Requisitos
- Python 3.11 o superior.
- Acceso a: Microsoft Graph (app registrada en Azure AD), SAP B1 Service Layer y SAP HANA.
## Instalación
```bash
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # y completar los valores
```
El `.env` nunca se sube a git. Todas las variables están documentadas en [`.env.example`](.env.example). Al arrancar, `config_validator` termina con un error claro si falta alguna obligatoria.
## Uso
```bash
python main.py # demonio; consulta los últimos 2 días (--dias 2)
python main.py --dias 5 # ventana de búsqueda de 5 días
python tools/consulta_rapida.py --set <nombre> # reprocesar correos por ID (sets en tools/ids_prueba.json)
python tools/verificar_conexiones.py # comprueba .env, Graph, SAP, HANA y la ruta de red
```
El demonio trabaja por bloques horarios (cada 1, 5, 30 o 60 minutos según la hora) y duerme fuera de ellos. Los bloques están en `HORARIOS` de `main.py` y se explican en [`docs/main.md`](docs/main.md).
## Despliegue
En producción corre como **servicio de Windows con NSSM**: arranca con el servidor y se reinicia solo. La carpeta [`deploy/`](deploy/) trae los scripts (instalar, actualizar a una versión etiquetada y volver atrás, desinstalar) y [`docs/despliegue_windows.md`](docs/despliegue_windows.md) la guía completa. Antes de instalar, `python tools/verificar_conexiones.py` comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red. Las versiones son etiquetas de git (`v1.2.1`); el historial está en [`CHANGELOG.md`](CHANGELOG.md).
## Pruebas
```bash
pip install -e ".[dev]" # instala pytest
pytest # ~95 pruebas, unos 3 segundos; no tocan SAP, HANA ni Outlook
```
- **Extracción** (`tests/test_extraccion_correos.py`): corre la extracción real sobre cada correo de `tests/fixtures/correos_prueba/` y la compara con `tests/golden/<caso>.json`. Esos resultados esperados fijan el comportamiento actual; no prueban que sea correcto. Si cambias la extracción a propósito, revisa el diff y regenéralos con `UPDATE_GOLDEN=1 pytest tests/test_extraccion_correos.py`.
- **Unitarias**: validación de precios, construcción del draft, notificaciones, guías, copia a red, filtrado de adjuntos y validación del `.env`.
- **Flujo completo** (`tests/test_pipeline_flujo.py`): `_procesar_un_correo` con SAP, HANA, Outlook y la BD simulados.
Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, etc.) son anteriores a la suite: escriben `xml_resultado_*.json` y `pdf_resultado_*.json` dentro de cada carpeta de `tests/fixtures/correos_prueba/`. Esos archivos no los usa ninguna prueba y pueden estar desactualizados. `tools/descargar_correos_prueba.py` **borra y vuelve a descargar** toda esa carpeta de correos reales.
## Carpetas de salida (ignoradas por git)
| Ruta | Contenido |
|------|-----------|
| `adjuntos/` | PDF/XML descargados (`DOWNLOAD_FOLDER`) |
| `resultado/` | JSON de datos extraídos y de drafts, para depuración (`OUTPUT_FOLDER`) |
| `logs/` | Log con rotación diaria y 30 días de historial. `LOG_LEVEL` controla el nivel |
| `emails.db` | Correos ya procesados y comprobantes con su código de seguimiento |
## Estructura del proyecto
| Módulo | Rol |
|--------|-----|
| `main.py` | Demonio y horarios |
| `pipeline.py` | Orquesta el proceso de cada correo |
| `adjuntos.py`, `documentos.py`, `persistencia.py` | Descarga de adjuntos, extracción de PDF/XML, guardado de JSON |
| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; correo técnico para el equipo |
| `respuesta_proveedor.py`, `observaciones.py`, `plantillas.py` | Respuesta de aceptado/rechazado al remitente con las plantillas HTML (`plantilla/`) |
| `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA |
| `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF |
| `database_utilities.py`, `seguimiento.py`, `email_utilities.py` | SQLite local; códigos de seguimiento y resultado por comprobante; ZIP y filtrado de adjuntos |
| `logger_manager.py`, `config_validator.py`, `pipeline_utils.py` | Logging, validación del `.env`, utilidades |
| `deploy/` | Scripts de PowerShell para instalar el servicio de Windows con NSSM |
| `tools/` | Scripts manuales: verificar conexiones, vista previa de las plantillas, reprocesar correos, regenerar los correos de prueba, `aberio.http` |
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
## Documentación
| Documento | Tema |
|-----------|------|
| [`docs/inventario.md`](docs/inventario.md) | Archivos, dependencias, flujo de datos y bugs corregidos |
| [`docs/main.md`](docs/main.md) | Demonio y horarios |
| [`docs/pipeline.md`](docs/pipeline.md) | Flujo por correo y funciones por módulo |
| [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML |
| [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP |
| [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 |
| [`docs/respuesta_proveedor.md`](docs/respuesta_proveedor.md) | Respuesta de aceptado/rechazado al proveedor: modos, protecciones y observaciones |
| [`docs/seguimiento.md`](docs/seguimiento.md) | Códigos de seguimiento y resultado por comprobante |
| [`docs/despliegue_windows.md`](docs/despliegue_windows.md) | Instalación como servicio de Windows con NSSM, operación y rollback |
| [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure |
| [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph |
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
+82
View File
@@ -0,0 +1,82 @@
"""Descarga y filtrado de adjuntos de un correo."""
import os
import logging
import outlook_utilities
from pipeline_utils import log, ruta_carpeta
# ============================================================================
def descargar_adjuntos(email: dict, email_addr: str, token: str, download_folder: str) -> list[str] | None:
"""
Descarga todos los adjuntos del correo usando Microsoft Graph.
Almacena la lista de nombres de archivo en ``email["adjuntos"]``.
Returns:
Lista de nombres de archivo descargados, o None si no hay adjuntos
o hubo un error.
Example:
Si el correo tiene ``factura.xml`` y ``guia.pdf``::
adjuntos = descargar_adjuntos(email, email_addr, token, download_folder)
# → ["factura.xml", "guia.pdf"]
# email["adjuntos"] = ["factura.xml", "guia.pdf"]
"""
try:
adjuntos = outlook_utilities.download_attachments(
email_addr, email["id"], token, download_folder
)
if adjuntos:
email["adjuntos"] = adjuntos
return adjuntos
return None
except Exception as ex:
log(f"❌ Error descargando adjuntos: {ex}", severity=logging.ERROR)
return None
# ============================================================================
def filtrar_y_limpiar(email: dict, download_folder: str) -> bool:
"""
Filtra los adjuntos conservando solo archivos PDF y XML.
- Los archivos que no son PDF ni XML se eliminan del disco.
- Si no queda ningún PDF/XML válido, se eliminan todos los archivos
descargados y se retorna False.
- Actualiza ``email["adjuntos"]`` con la lista filtrada.
Returns:
True si hay al menos un PDF o XML, False si no queda ninguno.
Example:
Adjuntos originales: ``[img.png, factura.xml, guia.pdf]``::
ok = filtrar_y_limpiar(email, download_folder)
# → img.png eliminado
# → ok = True
# → email["adjuntos"] = ["factura.xml", "guia.pdf"]
"""
todos = email.get("adjuntos", [])
validos = [a for a in todos if a.lower().endswith((".pdf", ".xml"))]
no_validos = [a for a in todos if a not in validos]
carpeta = ruta_carpeta(download_folder)
if not validos:
for a in todos:
ruta = os.path.join(carpeta, a)
if os.path.exists(ruta):
os.remove(ruta)
log("🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
return False
for a in no_validos:
ruta = os.path.join(carpeta, a)
if os.path.exists(ruta):
os.remove(ruta)
email["adjuntos"] = validos
return True
+98
View File
@@ -0,0 +1,98 @@
"""
Validación de la configuración (.env) al arrancar el proceso.
Falla rápido con un mensaje claro si falta alguna variable obligatoria, en
lugar de descubrirlo a mitad de un ciclo (por ejemplo, adjuntos descargados
en la raíz del proyecto por un ``DOWNLOAD_FOLDER`` vacío, o consultas a HANA
que devuelven 0 en silencio por credenciales ausentes).
"""
import os
import sys
import logging
logger = logging.getLogger(__name__)
# Variables obligatorias, agrupadas por servicio para el mensaje de error.
OBLIGATORIAS = {
"Pipeline": ["EMAIL", "DOWNLOAD_FOLDER", "OUTPUT_FOLDER"],
"Microsoft Graph": ["CLIENT_ID", "CLIENT_SECRET", "TENANT_ID"],
"SAP B1 Service Layer": ["SAP_B1_URL", "SAP_B1_COMPANY", "SAP_B1_USER", "SAP_B1_PASSWORD"],
"SAP HANA": ["HANA_SERVER", "HANA_USER", "HANA_PASSWORD", "HANA_SCHEMA"],
}
# Modos de ``NOTIFICACION_MODO``: ver respuesta_proveedor.py
MODOS_NOTIFICACION = ("prueba", "real")
# Variable de verificación SSL → servicio al que aplica. Cada módulo solo
# la activa con el valor "true" (cualquier otro valor, o ausente, la desactiva).
VERIFICACION_SSL = {
"VERIFY_SSL": "Microsoft Graph",
"SAP_B1_VERIFY_SSL": "SAP B1 Service Layer",
}
def validar_configuracion() -> tuple[list[str], list[str]]:
"""
Revisa las variables de entorno ya cargadas (tras ``load_dotenv``).
Returns:
tuple[list[str], list[str]]: ``(errores, advertencias)``. Los errores
impiden arrancar; las advertencias solo se registran.
"""
errores: list[str] = []
advertencias: list[str] = []
for servicio, variables in OBLIGATORIAS.items():
faltan = [v for v in variables if not os.getenv(v, "").strip()]
if faltan:
errores.append(f"{servicio}: faltan {', '.join(faltan)}")
ruta_red = os.getenv("RUTA_RED_FACTURACION", "").strip()
ruc_empresa = os.getenv("RUC_EMPRESA", "").strip()
if ruta_red and not ruc_empresa:
errores.append("Ruta de red: RUTA_RED_FACTURACION está definida pero falta RUC_EMPRESA")
elif not ruta_red:
advertencias.append(
"RUTA_RED_FACTURACION no definida: no se copiarán PDF/XML a la red "
"y U_VS_ANEXO_PDF/XML irán vacíos en el draft"
)
modo = os.getenv("NOTIFICACION_MODO", "").strip().lower()
if modo and modo not in MODOS_NOTIFICACION:
advertencias.append(f"NOTIFICACION_MODO='{modo}' no es válido (usa {' o '.join(MODOS_NOTIFICACION)}); se usará prueba")
elif modo == "real":
advertencias.append("Modo de notificación REAL: las respuestas se envían a quien envía el correo")
else:
advertencias.append("Modo de notificación PRUEBA: las respuestas van al equipo, no a quien envía el correo")
if not os.getenv("NOTIFICACION_EQUIPO", "").strip():
advertencias.append("NOTIFICACION_EQUIPO no definida: se usa la lista por defecto del equipo")
if not os.getenv("LOGO_URL", "").strip():
advertencias.append("LOGO_URL no definida: los correos al proveedor saldrán sin logo")
nivel = os.getenv("LOG_LEVEL", "").strip()
if nivel and not isinstance(getattr(logging, nivel.upper(), None), int):
advertencias.append(f"LOG_LEVEL='{nivel}' no es un nivel válido; se usará INFO")
for variable, servicio in VERIFICACION_SSL.items():
if os.getenv(variable, "").strip().lower() != "true":
advertencias.append(f"Verificación SSL desactivada para {servicio} ({variable} distinto de 'true')")
return errores, advertencias
def verificar_configuracion() -> None:
"""
Registra las advertencias y, si hay errores, los registra y termina el
proceso con código 1.
"""
errores, advertencias = validar_configuracion()
for a in advertencias:
logger.warning(f"⚠️ Configuración: {a}")
if errores:
logger.error("❌ Configuración incompleta en .env:")
for e in errores:
logger.error(f" - {e}")
sys.exit(1)
+60 -12
View File
@@ -1,17 +1,23 @@
import os
import sqlite3
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
DB_NAME = "emails.db"
# Junto al código, no relativa al directorio de trabajo: el demonio (por ejemplo como servicio de
# Windows) puede lanzarse desde cualquier carpeta y debe encontrar siempre la misma base.
DB_NAME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails.db")
# ============================================================================
def init_db() -> None:
"""
Inicializa la base de datos SQLite y crea la tabla de correos si no existe.
Inicializa la base de datos SQLite y crea las tablas si no existen:
``emails`` (correos ya procesados) y ``comprobantes`` (códigos de
seguimiento y resultado de cada comprobante, ver ``seguimiento.py``).
"""
conn = sqlite3.connect(DB_NAME)
try:
cursor = conn.cursor()
# Crear tabla con id como llave primaria para evitar duplicados
@@ -26,7 +32,50 @@ def init_db() -> None:
)
''')
# Un comprobante se identifica por (ruc_emisor, tipo, serie, numero). Los
# correos sin comprobante identificable llevan serie NULL y son únicos por correo.
cursor.execute('''
CREATE TABLE IF NOT EXISTS comprobantes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
codigo_seguimiento TEXT UNIQUE,
email_id TEXT NOT NULL,
ruc_emisor TEXT,
tipo TEXT,
serie TEXT,
numero TEXT,
estado TEXT NOT NULL DEFAULT 'pendiente',
observaciones TEXT,
doc_entry INTEGER,
veces_recibido INTEGER NOT NULL DEFAULT 1,
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
notificacion_error TEXT,
notificacion_fecha TEXT,
notificacion_destinatario TEXT,
intentos INTEGER NOT NULL DEFAULT 0,
creado_en TEXT NOT NULL,
actualizado_en TEXT NOT NULL
)
''')
# Bases creadas antes de agregar una columna: se añade sin tocar los datos.
columnas = {fila[1] for fila in cursor.execute("PRAGMA table_info(comprobantes)")}
for columna, definicion in (
("notificacion_destinatario", "TEXT"),
("intentos", "INTEGER NOT NULL DEFAULT 0"),
):
if columna not in columnas:
cursor.execute(f"ALTER TABLE comprobantes ADD COLUMN {columna} {definicion}")
cursor.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
''')
cursor.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_correo
ON comprobantes (email_id) WHERE serie IS NULL
''')
conn.commit()
finally:
conn.close()
# ============================================================================
@@ -39,6 +88,7 @@ def save_emails(emails_list: list[dict]) -> int:
return 0
conn = sqlite3.connect(DB_NAME)
try:
cursor = conn.cursor()
from datetime import datetime
@@ -65,9 +115,9 @@ def save_emails(emails_list: list[dict]) -> int:
''', data)
conn.commit()
rows_affected = cursor.rowcount
return cursor.rowcount
finally:
conn.close()
return rows_affected
# ============================================================================
def get_existing_ids(ids_list: list[str]) -> list[str]:
@@ -78,6 +128,7 @@ def get_existing_ids(ids_list: list[str]) -> list[str]:
return []
conn = sqlite3.connect(DB_NAME)
try:
cursor = conn.cursor()
existing_ids = []
@@ -89,8 +140,9 @@ def get_existing_ids(ids_list: list[str]) -> list[str]:
cursor.execute(query, chunk)
existing_ids.extend([row[0] for row in cursor.fetchall()])
conn.close()
return existing_ids
finally:
conn.close()
# ============================================================================
def delete_email_by_id(email_id: str) -> bool:
@@ -102,15 +154,11 @@ def delete_email_by_id(email_id: str) -> bool:
return False
conn = sqlite3.connect(DB_NAME)
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM emails WHERE id = ?", (email_id,))
conn.commit()
rows_affected = cursor.rowcount
return cursor.rowcount > 0
finally:
conn.close()
return rows_affected > 0
# ============================================================================
if __name__ == "__main__":
delete_email_by_id("AAMkADljMzNhYmQwLTQyMjgtNDY2My05ZDUwLWEzNTBiNDZhZjFlMwBGAAAAAAB386nfKhkcTbYV6pPUJ245BwBdNWALkxgeSJ3BdBRukthBAAAAAAEMAABdNWALkxgeSJ3BdBRukthBAAAtOXesAAA=")
+40
View File
@@ -0,0 +1,40 @@
<#
.SYNOPSIS
Actualiza el proyecto a una versión (etiqueta de git) y reinicia el servicio. Sirve también para volver a una
versión anterior (rollback). Ejecutar en PowerShell como administrador.
.EXAMPLE
.\deploy\actualizar.ps1 -Version v1.2.0
.\deploy\actualizar.ps1 -Version v1.1.0 # volver a la anterior
#>
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$Nombre = "descarga_factura_transporte",
[string]$Nssm = "nssm"
)
$ErrorActionPreference = "Stop"
$Proyecto = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $Proyecto
if (git status --porcelain --untracked-files=no) {
throw "Hay cambios locales sin guardar en $Proyecto. Revíselos (git status) antes de actualizar."
}
function Comprobar($paso) { if ($LASTEXITCODE -ne 0) { throw "Falló: $paso (código $LASTEXITCODE)" } }
git fetch --tags origin
Comprobar "git fetch"
if (-not (git tag --list $Version)) { throw "No existe la etiqueta $Version. Etiquetas disponibles: $(git tag --list | Out-String)" }
& $Nssm stop $Nombre confirm | Out-Null
try {
git checkout $Version
Comprobar "git checkout $Version"
& (Join-Path $Proyecto "venv\Scripts\python.exe") -m pip install -r requirements.txt
Comprobar "pip install"
} finally {
& $Nssm start $Nombre | Out-Null # arranca siempre: si algo falló, vuelve a levantar lo que había
}
Write-Host "Servicio '$Nombre' en la versión $Version."
Write-Host "Compruebe la versión en el log: Select-String 'versión' logs\descarga_factura_transporte.log | Select-Object -Last 1"
+15
View File
@@ -0,0 +1,15 @@
<#
.SYNOPSIS
Detiene y elimina el servicio de Windows del demonio. No borra el proyecto, el .env ni emails.db.
Ejecutar en PowerShell como administrador.
#>
param(
[string]$Nombre = "descarga_factura_transporte",
[string]$Nssm = "nssm"
)
$ErrorActionPreference = "Stop"
if (-not (Get-Service -Name $Nombre -ErrorAction SilentlyContinue)) { Write-Host "El servicio '$Nombre' no existe."; return }
& $Nssm stop $Nombre confirm | Out-Null
& $Nssm remove $Nombre confirm
Write-Host "Servicio '$Nombre' eliminado. El proyecto, el .env y emails.db no se tocaron."
+99
View File
@@ -0,0 +1,99 @@
<#
.SYNOPSIS
Instala (o reinstala) el demonio descarga_factura_transporte como servicio de Windows con NSSM.
.DESCRIPTION
Ejecutar en PowerShell COMO ADMINISTRADOR, desde cualquier carpeta. Ver docs/despliegue_windows.md.
No arranca el servicio: primero revisa el .env y luego ejecuta nssm start <nombre>.
.PARAMETER Cuenta
Cuenta con la que corre el servicio, en formato DOMINIO\usuario. Debe tener acceso de lectura/escritura a la
ruta de red de RUTA_RED_FACTURACION: la cuenta LocalSystem (por defecto de los servicios) NO ve recursos de red.
.PARAMETER Clave
Contraseña de la cuenta (SecureString). Si se omite, se pide por consola.
.EXAMPLE
.\deploy\instalar_servicio.ps1 -Cuenta "ABERIO\svc_facturas"
#>
param(
[string]$Nombre = "descarga_factura_transporte",
[string]$Cuenta = "",
[securestring]$Clave,
[string]$Nssm = "nssm",
[string]$Python = ""
)
$ErrorActionPreference = "Stop"
# --- Rutas (la carpeta del proyecto es la que contiene deploy\) ---------------------------------------------
$Proyecto = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
if (-not $Python) { $Python = Join-Path $Proyecto "venv\Scripts\python.exe" }
# --- Comprobaciones previas ----------------------------------------------------------------------------------
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "Ejecute este script como administrador."
}
if (-not (Get-Command $Nssm -ErrorAction SilentlyContinue)) {
throw "No se encontró nssm. Descárguelo de https://nssm.cc y agréguelo al PATH, o use -Nssm 'C:\ruta\nssm.exe'."
}
if (-not (Test-Path $Python)) {
throw "No existe $Python. Cree el entorno virtual: py -3 -m venv venv ; venv\Scripts\pip install -r requirements.txt"
}
if (-not (Test-Path (Join-Path $Proyecto ".env"))) {
throw "Falta $Proyecto\.env. Copie .env.example a .env y complételo (ver docs/configurar_msal.md)."
}
if (-not $Cuenta) {
Write-Warning "Sin -Cuenta el servicio corre como LocalSystem y NO podrá escribir en la ruta de red de facturación electrónica."
}
# --- Instalación ----------------------------------------------------------------------------------------------
if (Get-Service -Name $Nombre -ErrorAction SilentlyContinue) {
Write-Host "El servicio '$Nombre' ya existe: se detiene y se reconfigura."
& $Nssm stop $Nombre confirm | Out-Null
} else {
& $Nssm install $Nombre $Python "main.py" | Out-Null
}
$LogsNssm = Join-Path $Proyecto "logs"
New-Item -ItemType Directory -Force -Path $LogsNssm | Out-Null
& $Nssm set $Nombre Application $Python
& $Nssm set $Nombre AppParameters "main.py"
& $Nssm set $Nombre AppDirectory $Proyecto # el código no depende del cwd, pero el .env y los logs sí cuentan con él
& $Nssm set $Nombre DisplayName "Descarga de facturas de transporte"
& $Nssm set $Nombre Description "Lee el buzón, valida las facturas contra SAP B1, crea el draft y responde al proveedor."
& $Nssm set $Nombre Start SERVICE_AUTO_START
& $Nssm set $Nombre AppEnvironmentExtra "PYTHONUTF8=1" "PYTHONIOENCODING=utf-8" "PYTHONUNBUFFERED=1" # emojis del log en la consola de Windows
# Salida estándar y de error del proceso (el log de la aplicación sigue en logs\descarga_factura_transporte.log)
& $Nssm set $Nombre AppStdout (Join-Path $LogsNssm "servicio.out.log")
& $Nssm set $Nombre AppStderr (Join-Path $LogsNssm "servicio.err.log")
& $Nssm set $Nombre AppRotateFiles 1
& $Nssm set $Nombre AppRotateOnline 1
& $Nssm set $Nombre AppRotateBytes 10485760 # 10 MB
# Reinicio automático si el proceso muere; espera 30 s para no entrar en un bucle rápido
& $Nssm set $Nombre AppExit Default Restart
& $Nssm set $Nombre AppRestartDelay 30000
& $Nssm set $Nombre AppThrottle 10000
# Parada limpia: Ctrl+C (el demonio lo atiende con KeyboardInterrupt) y 20 s de gracia antes de forzar
& $Nssm set $Nombre AppStopMethodSkip 0
& $Nssm set $Nombre AppStopMethodConsole 20000
& $Nssm set $Nombre AppStopMethodWindow 0
& $Nssm set $Nombre AppStopMethodThreads 0
if ($Cuenta) {
if (-not $Clave) { $Clave = Read-Host "Contraseña de $Cuenta" -AsSecureString }
$Texto = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Clave))
& $Nssm set $Nombre ObjectName $Cuenta $Texto
Remove-Variable Texto
}
Write-Host ""
Write-Host "Servicio '$Nombre' instalado. Pasos siguientes:"
Write-Host " 1. Revise $Proyecto\.env (NOTIFICACION_MODO, NOTIFICACION_EQUIPO, LOGO_URL)."
Write-Host " 2. nssm start $Nombre"
Write-Host " 3. Get-Content '$LogsNssm\descarga_factura_transporte.log' -Wait -Tail 30"
+88
View File
@@ -0,0 +1,88 @@
# Configurar la aplicación de Microsoft Graph (MSAL)
El proyecto lee y envía correo con Microsoft Graph usando una **aplicación registrada en Microsoft Entra ID** (antes Azure AD) y el flujo *client credentials*: no hay usuario interactivo, la aplicación se autentica sola con un `CLIENT_ID` y un `CLIENT_SECRET`. Este documento explica cómo obtener cada valor del `.env`.
| Variable del `.env` | De dónde sale |
|---------------------|---------------|
| `CLIENT_ID` | *Application (client) ID* de la aplicación |
| `TENANT_ID` | *Directory (tenant) ID* de la aplicación |
| `CLIENT_SECRET` | **Value** de un *client secret* creado en esa misma aplicación |
| `EMAIL` | Dirección del buzón que se lee y desde el que se envía |
> Los pasos describen el portal de Azure tal como suele verse; los nombres de los menús cambian de vez en cuando.
## 1. Registrar la aplicación
1. Entra a <https://entra.microsoft.com> (o al portal de Azure) con una cuenta con permisos para registrar aplicaciones.
2. **Identity → Applications → App registrations → New registration**.
3. Nombre, por ejemplo `descarga-factura-transporte`. En *Supported account types* elige **Accounts in this organizational directory only** (un solo tenant). No hace falta *Redirect URI*.
4. En la página **Overview** de la aplicación copia:
- **Application (client) ID** → `CLIENT_ID`
- **Directory (tenant) ID** → `TENANT_ID`
Los dos tienen que salir de la **misma** aplicación. Un `CLIENT_ID` de una aplicación con el `TENANT_ID` de otro directorio da el error `AADSTS700016`.
## 2. Dar permisos
Con el flujo *client credentials* se usan permisos de **aplicación**, no delegados.
1. **API permissions → Add a permission → Microsoft Graph → Application permissions**.
2. Agrega solo lo que el código usa:
| Permiso | Para qué | Dónde se usa |
|---------|----------|--------------|
| `Mail.Read` | Listar la bandeja de entrada y descargar adjuntos | `get_emails`, `download_attachments` |
| `Mail.Send` | Enviar las notificaciones | `send_email` |
3. Pulsa **Grant admin consent for <tu organización>**. Sin el consentimiento de administrador los permisos de aplicación no tienen efecto, aunque aparezcan en la lista.
> **Ojo:** un permiso de aplicación de correo vale, por defecto, para **todos los buzones** del tenant. Conviene limitarlo al buzón del proyecto con una política de acceso de aplicaciones en Exchange Online (*Application Access Policy* o *RBAC for Applications*). Eso lo configura un administrador de Exchange.
## 3. Crear el client secret
1. **Certificates & secrets → Client secrets → New client secret**.
2. Descripción y vencimiento (el portal permite hasta 24 meses).
3. Al crearlo, copia la columna **Value** y guárdala de inmediato en `CLIENT_SECRET`. **Solo se muestra una vez.**
Se confunde con frecuencia: la columna **Secret ID** es un identificador, no sirve como secret. Si usas el *Secret ID* el error es `AADSTS7000215`.
Cada secret **vence**. Anota la fecha en un calendario: cuando vence, el demonio deja de poder autenticarse y no lee ni envía correo (error `AADSTS7000222`). Para renovar sin cortes, crea el secret nuevo antes, actualiza el `.env`, reinicia el demonio y recién entonces borra el viejo.
## 4. Completar el `.env`
```
CLIENT_ID = <Application (client) ID>
TENANT_ID = <Directory (tenant) ID>
CLIENT_SECRET = <Value del secret>
EMAIL = buzon@tu-dominio.com
```
El `.env` no se sube a git. No pegues el secret en chats, tickets ni commits.
## 5. Verificar
Pide un token sin enviar nada. Imprime solo el resultado, no el secret:
```bash
python -c "
import re, outlook_utilities as o
try:
o.get_access_token(); print('OK: token obtenido')
except Exception as e:
print('ERROR', re.findall(r'AADSTS\d+', str(e)) or e)"
```
Un `OK` confirma `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET`. No confirma los permisos sobre el buzón: eso se comprueba al listar o enviar un correo, por ejemplo con `tools/previsualizar_plantillas.py --escenario aceptado_sin_pago --enviar tu@correo`.
## Errores frecuentes
| Código | Significa | Qué revisar |
|--------|-----------|-------------|
| `AADSTS7000222` | El client secret **venció** | Crear un secret nuevo (paso 3) |
| `AADSTS7000215` | Secret inválido | Se usó el *Secret ID* en vez del *Value*, o hay un error al copiarlo |
| `AADSTS700016` | La aplicación no existe en ese directorio | `CLIENT_ID` y `TENANT_ID` deben ser de la misma aplicación (paso 1) |
| `AADSTS90002` | El tenant no existe | `TENANT_ID` mal escrito |
| `403` / `ErrorAccessDenied` al leer o enviar | Faltan permisos o consentimiento, o una política de acceso excluye al buzón | Paso 2 y la política de Exchange |
| `404` / `MailboxNotFound` | El buzón de `EMAIL` no existe o no tiene licencia | Revisar `EMAIL` |
Para cualquier código `AADSTS`, el mensaje completo trae un *Trace ID* y una explicación; la lista oficial está en la documentación de Microsoft Entra, sección de códigos de error de autenticación.
+40
View File
@@ -0,0 +1,40 @@
# Construir Draft SAP B1
Función: `draft_sap.build_sap_draft(factura, resultados_val)` → dict listo para `POST /Drafts`.
```mermaid
flowchart TD
A["factura + resultados_val<br>(de validate_prices)"] --> B["ItemCode según fecha_emision:<br>C103002-A (mes anterior)<br>C103002 (mes actual)"]
B --> B2["CardCode = PL + ruc_emisor<br>sapb1_manager.obtener_condicion_pago(CardCode)<br>(BusinessPartners.PayTermsGrpCode → PaymentTermsTypes)"]
B2 --> B3["DocDueDate = fecha_contabilizacion (hoy) +<br>NumberOfAdditionalDays<br>(si es 0, NumberOfAdditionalMonths * 30)"]
B3 --> C["Calcular cuotas<br>DocumentInstallments"]
C --> D{"¿Tiene detracción?"}
D -->|Sí| E["monto_detraccion = ceil(monto)<br>Cuota 1: monto_detraccion<br>U_VS_TIPOCUOTA = D<br>U_VS_TIPOSERVICIO = codigo + '02'"]
E --> F["Cuota 2: neto = total - monto_detraccion<br>U_VS_TIPOCUOTA = N"]
D -->|No| G["Cuota única por el total<br>U_VS_TIPOCUOTA = N"]
F --> H["Por cada línea de factura (índice i)"]
G --> H
H --> I["guias_lt[] = resultados_val[i]['guias_lt']<br>(si no hay, se salta la línea)"]
I --> K["Por cada item en guias_lt[]"]
K --> L["PriceAfterVAT = draft_price<br>truncado a 6 decimales"]
L --> M["Quantity = draft_quantity"]
M --> N["Crear DocumentLine:<br>ItemCode, Quantity, PriceAfterVAT,<br>WarehouseCode, FreeText (descripción[:100]),<br>campos fijos U_* / Costing / TaxCode I18,<br>U_IDC_GRFLETE = tipo+serie-numero de la guía"]
N --> K
K --> P["U_IDC_VALREF = suma de valor_referencial<br>de las líneas del XML"]
P --> Q["U_IDC_NUMGR = primera guía de la<br>última línea que tuvo guías"]
Q --> R["Cabecera: DocType, DocDate = hoy,<br>DocDueDate, TaxDate = fecha_emision,<br>CardCode, NumAtCard, Comments,<br>U_VS_ANEXO_PDF / U_VS_ANEXO_XML<br>(rutas de red de copiar_a_red)"]
R --> S["DocumentLines[] + DocumentInstallments[]"]
S --> T["← Retornar dict para POST /Drafts"]
```
## Notas
- `draft_quantity` y `draft_price` los fija `validate_prices`: con `ok=True` salen de SAP (`int(max Quantity FL*)` y suma de `PriceAfterVAT` FL*); con `ok=False` salen del XML (`int(cantidad)` y `total_linea / cantidad`). Ver `validar_precios.md`.
- El valor referencial del draft (`U_IDC_VALREF`) sale del XML SUNAT (`sunat_schema`), no de SAP. La comparación contra SAP (`Sp_ObtenerValorReferencial`, vía `hana_manager`) solo se muestra en el correo de notificación.
- El `DueDate` de las cuotas es el mismo `fecha_vencimiento` que usa la cabecera (`DocDueDate`): fecha de contabilización + días de la condición de pago. `TaxDate` es la única fecha que lleva la emisión del comprobante.
- Si `obtener_condicion_pago` no encuentra condición para el proveedor, `DocDueDate` = fecha de contabilización.
-33
View File
@@ -1,33 +0,0 @@
# Construir Draft SAP B1
```mermaid
flowchart TD
A["factura + resultados_val"] --> B["Determinar ItemCode<br>C103002-A (mes anterior)<br>C103002 (mes actual)"]
B --> C["Calcular cuotas<br>DocumentInstallments"]
C --> D{"¿Tiene detracción?"}
D -->|Sí| E["Cuota 1: monto detracción<br>U_VS_TIPOCUOTA = D"]
E --> F["Cuota 2: neto = total - detracción<br>U_VS_TIPOCUOTA = N"]
D -->|No| G["Cuota única por el total<br>U_VS_TIPOCUOTA = N"]
F --> H["Por cada línea de factura"]
G --> H
H --> I["Obtener guias_lt[]<br>desde resultado_validacion"]
I --> J["zona_guia = guias_lt[0]['zona']"]
J --> K["Por cada item en guias_lt[]"]
K --> L["PriceAfterVAT:<br>suma pav FL* si use_sap_prices<br>line_total si no"]
L --> M["Qty = item['quantity']"]
M --> N["Crear DocumentLine:<br>ItemCode, Quantity,<br>PriceAfterVAT, WarehouseCode,<br>U_IDC_GRFLETE"]
N --> O["Acumular total_weight<br>+= item['weight1']"]
O --> K
K --> P["U_IDC_VALREF =<br>round((total_weight / 1000) *<br>CUADRO_VALOR_REFERENCIAL[zona], 2)"]
P --> Q["Armar payload completo"]
Q --> R["Cabecera:<br>DocType, CardCode, NumAtCard,<br>DocDate, DocDueDate,<br>U_IDC_NUMGR, Comments"]
R --> S["DocumentLines[] +<br>DocumentInstallments[]"]
S --> T["← Retornar dict para<br>POST /Drafts"]
```
+109
View File
@@ -0,0 +1,109 @@
# Despliegue en Windows (servicio con NSSM)
El demonio (`main.py`) corre como **servicio de Windows** gestionado con [NSSM](https://nssm.cc): arranca con el servidor, se reinicia solo si se cae y se detiene de forma ordenada. La carpeta `deploy/` trae los scripts.
> Los scripts de `deploy/` se revisaron con el analizador de PowerShell (sintaxis correcta) pero **no se ejecutaron en un Windows real**. Pruébelos primero en un servidor de pruebas.
## Requisitos del servidor
| Requisito | Detalle |
|-----------|---------|
| Windows | Server 2016 o superior, o Windows 10/11 |
| Python | 3.11 o superior, **64 bits** (el cliente de SAP HANA, `hdbcli`, lo requiere) |
| Git y NSSM | `git` en el PATH y `nssm.exe` en el PATH (<https://nssm.cc>) |
| Red hacia fuera | `login.microsoftonline.com` y `graph.microsoft.com` (443) |
| Red hacia SAP | Service Layer (`SAP_B1_URL`, puerto 50000 por defecto) y HANA (`HANA_SERVER`, puerto 30015) |
| Ruta de red | Acceso de lectura y escritura a `RUTA_RED_FACTURACION` (recurso UNC) |
| Cuenta de servicio | Una cuenta de dominio, por ejemplo `ABERIO\svc_facturas`, con acceso a esa ruta de red |
| Zona horaria | El código asume hora de Lima (UTC-5) para la fecha y hora de recepción que ve el proveedor |
**La cuenta de servicio es importante.** Por defecto un servicio corre como `LocalSystem`, que **no ve recursos de red**: la copia a la ruta de facturación electrónica fallaría. Use una cuenta con permiso sobre ese recurso.
## Instalación
En PowerShell **como administrador**:
```powershell
cd C:\apps
git clone https://unasgeorge.vaquita-hadar.ts.net/george/descarga_factura_transporte.git
cd descarga_factura_transporte
git checkout v1.2.1 # siempre una versión etiquetada, no main
py -3 -m venv venv
venv\Scripts\python -m pip install -r requirements.txt
copy .env.example .env # y completar los valores
```
Complete el `.env` (cómo obtener las credenciales de Microsoft: [configurar_msal.md](configurar_msal.md)). **Empiece con `NOTIFICACION_MODO=prueba`**: las respuestas van al equipo y no a los proveedores. Defina `NOTIFICACION_EQUIPO` y `LOGO_URL`.
### Verificar antes de instalar el servicio
```powershell
venv\Scripts\python tools\verificar_conexiones.py
```
Revisa, sin modificar nada ni imprimir secretos: el `.env`, el token y la lectura del buzón de Microsoft Graph, SAP Service Layer, SAP HANA y la ruta de red de facturación (que debe ser accesible y escribible con la cuenta del servicio). Termina con código 1 si algo falla. Opcionalmente, las pruebas (no tocan SAP ni Outlook):
```powershell
venv\Scripts\python -m pip install pytest==9.1.1
venv\Scripts\python -m pytest -q
```
### Instalar y arrancar el servicio
```powershell
.\deploy\instalar_servicio.ps1 -Cuenta "ABERIO\svc_facturas" # pide la contraseña
nssm start descarga_factura_transporte
```
El script configura: arranque automático, directorio de trabajo del proyecto, reinicio automático a los 30 s si el proceso muere, parada con Ctrl+C (20 s de gracia), rotación de la salida del servicio a 10 MB y las variables `PYTHONUTF8=1` y `PYTHONIOENCODING=utf-8` (sin ellas los emojis del log dan error en la consola de Windows).
## Comprobar que funciona
```powershell
Get-Service descarga_factura_transporte
Get-Content logs\descarga_factura_transporte.log -Wait -Tail 30
```
En el log, al arrancar, debe verse `🚀 Demonio iniciado … (versión 1.2.1)`, y `config_validator` anuncia el modo de notificación (`PRUEBA` o `REAL`).
## Operación
| Tema | Detalle |
|------|---------|
| Logs de la aplicación | `logs\descarga_factura_transporte.log`, rotación diaria, 30 días |
| Salida del servicio | `logs\servicio.out.log` y `servicio.err.log` (10 MB, rotan). Mire `servicio.err.log` si el servicio arranca y se detiene |
| Cambiar el `.env` | `nssm restart descarga_factura_transporte` |
| Horarios | `HORARIOS` en `main.py` (24 h cubiertas, ver [main.md](main.md)) |
| Copia de seguridad | `emails.db` guarda los correos procesados y los códigos de seguimiento. Cópielo con el servicio detenido, o con `sqlite3 emails.db ".backup respaldo.db"` |
| Espacio en disco | `adjuntos\` y `resultado\` **no se limpian solos**: planifique una limpieza periódica |
| Pasar a modo real | Cuando el equipo haya validado las respuestas: `NOTIFICACION_MODO=real` y reiniciar |
## Actualizar y volver atrás (rollback)
Siempre a una **versión etiquetada** (ver [CHANGELOG](../CHANGELOG.md)):
```powershell
.\deploy\actualizar.ps1 -Version v1.2.1
.\deploy\actualizar.ps1 -Version v1.2.0 # volver a la anterior
```
El script detiene el servicio, hace `git checkout` de la etiqueta, instala las dependencias y arranca de nuevo (aunque algo falle, vuelve a levantar lo que había). No toca el `.env` ni `emails.db`. Se niega a actuar si hay cambios locales sin guardar.
## Desinstalar
```powershell
.\deploy\desinstalar_servicio.ps1 # no borra el proyecto, el .env ni emails.db
```
## Problemas frecuentes
| Síntoma | Causa probable |
|---------|----------------|
| El servicio arranca y se detiene enseguida | Falta el `.env`, o `config_validator` rechazó una variable: ver `logs\servicio.err.log` |
| `Access is denied` al copiar a la ruta de red | El servicio corre como `LocalSystem`. Reinstale con `-Cuenta` o cambie la cuenta en `services.msc` |
| `UnicodeEncodeError` en la salida del servicio | Falta `PYTHONUTF8=1`: reinstale con `instalar_servicio.ps1` |
| `AADSTS7000222` / `AADSTS700016` | Secret vencido / `CLIENT_ID` y `TENANT_ID` de aplicaciones distintas: ver [configurar_msal.md](configurar_msal.md) |
| `ConnectTimeout` hacia SAP o HANA | VPN o firewall: abra el puerto 50000 (Service Layer) y 30015 (HANA) desde el servidor |
| Los correos llegan sin logo | `LOGO_URL` vacío o no accesible públicamente |
+56
View File
@@ -0,0 +1,56 @@
# hana_manager.py — SAP HANA directo
Acceso de solo lectura a SAP HANA con `hdbcli`. Se usa para dos cosas:
1. **Fallback de `sapb1_manager.get_delivery_notes`**: ubicar una guía de remisión cuando el Service Layer no la encuentra.
2. **Valor referencial por guía**: ejecutar `Sp_ObtenerValorReferencial` (lo consume `draft_sap.obtener_valor_referencial_sap`).
## Estructura
```
hana_manager.py
├── CONFIG (.env): HANA_SERVER, HANA_PORT (30015), HANA_USER, HANA_PASSWORD, HANA_SCHEMA
├── get_connection() → conexión | None
├── get_delivery(tipo, serie, numero) → list[dict]
│ ├── _query_delivery_table(conn, "ODLN", ...) (Entregas)
│ └── _query_delivery_table(conn, "OWTR", ...) (Traslados; solo si ODLN no devolvió nada)
└── obtener_valor_referencial(serie, numero) → float
```
## `get_delivery(tipo, serie, numero)`
```mermaid
flowchart TD
A[get_delivery] --> B{get_connection OK?}
B -- No --> Z["return []"]
B -- Sí --> C["Consultar ODLN"]
C --> D{¿Hay resultados?}
D -- Sí --> R["return resultados (Tabla = 'ODLN')"]
D -- No --> E["Consultar OWTR"]
E --> R2["return resultados (Tabla = 'OWTR') o []"]
```
Filtro aplicado en ambas tablas:
| Campo SAP | Valor |
|-----------|-------|
| `CANCELED` | `'N'` |
| `U_BPP_MDTD` | `tipo` (código SUNAT: `09`, `31`) |
| `U_BPP_MDSD` | `serie` |
| `U_BPP_MDCD` | `LIKE '%numero%'` |
Cada registro devuelto trae `DocEntry`, `DocNum`, `CardCode`, `CardName` y `Tabla`. El campo `Tabla` le dice a `sapb1_manager` qué endpoint del Service Layer usar después: `DeliveryNotes` (ODLN) o `StockTransfers` (OWTR, que además requiere resolver la orden de venta por `U_IDC_NUMOV`).
## `obtener_valor_referencial(serie, numero)`
Ejecuta `CALL "<schema>"."Sp_ObtenerValorReferencial"(serie, numero)` y devuelve la columna `ValorReferencial` del primer registro. Retorna `0.0` si no hay filas, si el valor es nulo o si ocurre un error.
## Comportamiento ante errores
Ninguna función lanza excepciones: todas registran un `ERROR` en el log y devuelven un valor neutro (`None` en `get_connection`, `[]` en `get_delivery`, `0.0` en `obtener_valor_referencial`). Por eso las credenciales de HANA son obligatorias en `config_validator`: sin ellas el pipeline seguiría ejecutándose con valores referenciales en 0 sin detenerse.
## Notas
- Cada llamada abre y cierra su propia conexión (no hay pool).
- El número se busca con `LIKE '%numero%'`: es una coincidencia parcial, así que un número corto podría coincidir con más de un documento de la misma serie.
- El esquema se interpola en el SQL desde `HANA_SCHEMA` (`.env`); los valores de búsqueda sí van como parámetros (`?`).
+104 -37
View File
@@ -4,17 +4,33 @@
| 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 |
| `main.py` | 142 | Demonio orquestador (loop de horarios) |
| `pipeline.py` | 437 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
| `pipeline_utils.py` | 38 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
| `documentos.py` | 378 | Extracción PDF/XML, asignación de guías, copia a red |
| `persistencia.py` | 165 | Guardado de JSON de resultados y de drafts |
| `draft_sap.py` | 444 | Validación de precios contra SAP, valor referencial y construcción del draft |
| `notificaciones.py` | 121 | Cuerpos HTML de los correos de notificación |
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
| `sapb1_manager.py` | 469 | Comunicación con SAP B1 Service Layer |
| `outlook_utilities.py` | 274 | Comunicación con Microsoft Graph |
| `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
| `database_utilities.py` | 164 | SQLite local (cache de correos) |
| `tools/*_prueba.py`, `tools/completar_guias_pipeline.py`, `tools/explorar_notes_xml.py` | — | Scripts manuales sobre `tests/fixtures/correos_prueba/` (anteriores a la suite pytest) |
| `tools/verificar_conexiones.py` | 85 | Comprueba `.env`, Graph, SAP, HANA y la ruta de red sin modificar nada |
| `deploy/*.ps1` | — | Instalar, actualizar (versión etiquetada / rollback) y desinstalar el servicio de Windows con NSSM |
| `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) |
| `seguimiento.py` | 226 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
| `respuesta_proveedor.py` | 300 | Respuesta de aceptado/rechazado al remitente (modo prueba/real, protecciones, contextos) |
| `observaciones.py` | 165 | Catálogo de motivos de rechazo y sus textos |
| `plantillas.py` | 123 | Renderizador de las plantillas HTML de correo |
| `config_validator.py` | 98 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
| `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
Documentación por módulo: despliegue → [despliegue_windows.md](despliegue_windows.md), `respuesta_proveedor.py`/`observaciones.py`/`plantillas.py` → [respuesta_proveedor.md](respuesta_proveedor.md), `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md).
---
@@ -22,17 +38,34 @@
```
main.py
├── logger_manager.py
└── pipeline.py
├── sunat_schema.py
├── pdf_utilities.py
├── sapb1_manager.py
│ └── hana_manager.py
├── hana_manager.py (valor referencial, Sp_ObtenerValorReferencial)
├── outlook_utilities.py
├── email_utilities.py
│ └── database_utilities.py
└── database_utilities.py
```
Módulos que `pipeline.py` orquesta (todos usan `pipeline_utils.py`):
```
pipeline.py
├── adjuntos.py → outlook_utilities
├── documentos.py → sunat_schema, pdf_utilities
├── persistencia.py → sunat_schema, pdf_utilities
├── draft_sap.py → sapb1_manager, hana_manager
└── notificaciones.py
tools/consulta_rapida.py
├── logger_manager.py
├── database_utilities.py
└── pipeline.py (_procesar_un_correo, step_authenticate)
```
---
## Flujo de datos
@@ -44,20 +77,28 @@ main()
├── 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
├── adjuntos.descargar_adjuntos() → archivos
├── email_utilities.descomprimir_adjuntos() ZIPs
├── adjuntos.filtrar_y_limpiar() → solo PDF/XML
├── documentos.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)
├── documentos.procesar_xmls(pdf_cache) → documentos[]
│ ├── sunat_schema.extraer_datos_xml()
│ └── documentos.copiar_a_red() → copia PDF/XML a ruta de red (RUTA_RED_FACTURACION)
├── documentos.completar_guias(documentos, pdf_cache) → completa guías faltantes desde PDF
├── documentos.validar_guias(documentos) → advierte guías con datos incompletos
├── persistencia.guardar_resultado(documentos)
├── draft_sap.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)
│ ├── HANA fallback (get_delivery: ODLN, luego OWTR)
│ └── si vino de OWTR: GET StockTransfers(DocEntry)
│ → U_IDC_NUMOV → GET Orders(U_IDC_NUMOV)
├── draft_sap.obtener_valor_referencial_sap(factura) → guias_valor_ref
│ └── hana_manager.obtener_valor_referencial() (Sp_ObtenerValorReferencial)
├── draft_sap.build_sap_draft(factura, resultados) → draft (incluye U_VS_ANEXO_PDF/XML)
│ └── sapb1_manager.obtener_condicion_pago(CardCode) → DocDueDate
├── persistencia.guardar_draft_json(draft)
├── sapb1_manager.create_draft(draft)
└── outlook_utilities.send_email(notificación)
```
@@ -75,21 +116,47 @@ main()
| 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 |
| 8 | Bloque `if __name__ == "__main__"` borraba un correo real de la BD al ejecutar el módulo directo | `database_utilities.py` | ✅ Corregido |
| 9 | `extraer_datos_xml` solo procesaba guías tipo `31`; tipo `09` (remitente) se descartaba pese a estar documentado | `sunat_schema.py` | ✅ Corregido |
| 10 | `DatosComprobante.__str__` usaba `ref.id` (campo que ya no existe en `DocumentoReferencia`) | `sunat_schema.py` | ✅ Corregido |
| 11 | Log de error HTTP con `print("%s", ...)` no interpolaba el mensaje | `sapb1_manager.py` | ✅ Corregido |
| 12 | Fuga de conexión HANA si `cursor()` fallaba (quedaba fuera del `try`) | `hana_manager.py` | ✅ Corregido |
| 13 | `razon_social_emisor` se llenaba con el nombre del receptor en el JSON de depuración del PDF | `pipeline.py` | ✅ Corregido |
| 14 | Typo `VERIFI_SSL` en vez de `VERIFY_SSL` | `outlook_utilities.py` / `.env` | ✅ Corregido |
| 15 | Conexiones SQLite sin `try/finally` (fuga si fallaba una consulta a mitad de camino) | `database_utilities.py` | ✅ Corregido |
| 16 | Código muerto sin llamadores (`quitar_correos_ya_registrados_en_base_de_datos`, `procesar_adjuntos_email`) | `email_utilities.py` | ✅ Corregido (eliminado) |
| 17 | `logger_utilities.py` (redirección global de stdout, sin niveles ni rotación real) reemplazado por `logger_manager.py` (logging estándar, rotación diaria 30 días, captura de `print()`) | `logger_manager.py` | ✅ Corregido |
| 18 | `task()`/`last_task()` de `logger_manager.py` no devolvían el sub-logger (`with ... as sub` daba siempre `None`) | `logger_manager.py` | ✅ Corregido |
| 19 | `DOWNLOAD_FOLDER` duplicado en `.env` | `.env` | ✅ Corregido |
| 20 | Guías tipo `09` no se encontraban si el documento estaba en `OWTR` (Traslado de Inventario) en vez de `ODLN` | `hana_manager.py` | ✅ Corregido (fallback ODLN → OWTR) |
| 21 | Parámetros `tipo`/`serie` cruzados al llamar `get_delivery`/`get_delivery_notes` (mapeaban al revés a `U_BPP_MDSD`/`U_BPP_MDTD`) | `hana_manager.py`, `sapb1_manager.py`, `pipeline.py` | ✅ Corregido (orden natural `tipo, serie, numero`) |
| 22 | `StockTransfers` (OWTR) no tiene líneas `FL*`, por lo que la validación de precios fallaba siempre para esas guías | `sapb1_manager.py` | ✅ Corregido (resuelve la Orden de Venta vía `U_IDC_NUMOV` y valida contra `Orders(DocEntry)`) |
| 23 | `DocDueDate` del draft usaba `fecha_emision` del XML, sin considerar la condición de pago del proveedor | `pipeline.py`, `sapb1_manager.py` | ✅ Corregido (`DocDueDate` = fecha de contabilización + días de `PaymentTermsTypes`) |
| 24 | Patrón regex de valor referencial no reconocía la variante truncada "VALOR REFER" (ej. "VALOR REFER:S/ 1431.16") | `sunat_schema.py` | ✅ Corregido |
---
## Funciones internas en `_procesar_un_correo` (pipeline.py)
## Funciones por módulo
| 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 |
> Antes eran funciones anidadas en `_procesar_un_correo`; ahora son funciones de módulo que reciben explícitamente `email`, `download_folder`, `output_folder`, etc. `pipeline.py` solo las orquesta.
| Módulo | Función | Propósito |
|--------|---------|-----------|
| `pipeline_utils` | `ruta_carpeta` | Resuelve una ruta relativa al directorio del proyecto |
| `pipeline_utils` | `log` | Imprime con indentación de árbol (niveles 1-4) |
| `adjuntos` | `descargar_adjuntos` | Descarga adjuntos vía Graph API |
| `adjuntos` | `filtrar_y_limpiar` | Filtra PDF/XML, elimina el resto |
| `documentos` | `copiar_a_red` | Copia PDF/XML a la ruta de red de Facturación Electrónica (`RUTA_RED_FACTURACION`) |
| `documentos` | `procesar_pdfs` | Extrae datos de PDFs, renombra, genera cache |
| `documentos` | `procesar_xmls` | Procesa XMLs, cruza con PDF, asigna guías, copia a red |
| `documentos` | `completar_guias` | Completa guías faltantes en líneas XML desde el PDF cache |
| `documentos` | `validar_guias` | Advierte (log) guías con datos incompletos |
| `persistencia` | `guardar_resultado` | Guarda JSON con datos extraídos (XML y PDF) |
| `persistencia` | `guardar_draft_json` | Guarda draft JSON para depuración |
| `draft_sap` | `calcular_fecha_vencimiento` | Calcula `DocDueDate` = fecha de contabilización + días de la condición de pago (`PaymentTermsTypes`) |
| `draft_sap` | `build_sap_draft` | Construye payload del draft SAP B1 |
| `draft_sap` | `validate_prices` | Valida precios contra DeliveryNotes / Orders (SAP) |
| `draft_sap` | `obtener_valor_referencial_sap` | Consulta `Sp_ObtenerValorReferencial` en HANA por guía |
| `notificaciones` | `build_validation_html` | Genera tabla HTML de validación de precios |
| `notificaciones` | `build_valor_referencial_html` | Genera tabla HTML comparando valor referencial XML vs SAP |
| `notificaciones` | `build_notification_body` | Arma cuerpo del email de notificación |
+27 -10
View File
@@ -4,11 +4,11 @@
```
main.py
├── Constantes: HORARIOS (4 bloques horarios)
├── Constantes: HORARIOS (7 bloques que cubren las 24 horas)
├── obtener_intervalo(dt) → (intervalo, modo) | None
├── segundos_hasta_proximo_inicio(dt) → int
├── non_negative_int(value) → int (validador argparse)
└── main() → None
└── main() → None (bucle infinito envuelto en try/except KeyboardInterrupt)
```
## Proceso de `main()`
@@ -17,7 +17,8 @@ main.py
flowchart TD
A[Inicio main] --> B[Parsear args --dias]
B --> C[Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER]
C --> D{Bucle infinito}
C --> Z[try:]
Z --> D{Bucle infinito}
D --> E[inicio_ciclo = datetime.now]
E --> F[obtener_intervalo inicio_ciclo]
@@ -37,6 +38,8 @@ flowchart TD
O --> P[Log próxima ejecución]
P --> N
D -. KeyboardInterrupt .-> Q[🛑 Demonio detenido por el usuario]
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
@@ -49,7 +52,8 @@ main()
│
├── 2. Cargar .env → EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER
│
├── 3. LOOP INFINITO (while True):
├── 3. try:
│ └── LOOP INFINITO (while True):
│ │
│ ├── 3.1. inicio_ciclo = datetime.now()
│ │
@@ -65,28 +69,41 @@ main()
│ │
│ └── ¿No está en horario?
│ │
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente)
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente; rama inalcanzable mientras los bloques cubran el día)
│ └── Log "Fuera del horario, próxima ejecución en..."
│ │
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
│
└── 4. time.sleep(dormir) → vuelve a 3.1
└── except KeyboardInterrupt: 🛑 "Demonio detenido por el usuario" (sin traceback)
```
## Bloques horarios definidos
| Desde | Hasta | Intervalo | Modo |
|---------|---------|-----------|----------|
| 00:00 | 05:00 | 60s | rápido |
| 05:00 | 06:30 | 1800s | full |
| 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 |
| 21:30 | 24:00 | 3600s | full |
Fuera de ese rango → duerme hasta las 06:30 del día siguiente.
Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba `tests/test_horarios.py`). El modo (`rápido`/`full`) solo se registra en el log: `run_pipeline` se ejecuta igual en ambos. Antes de la versión 1.2.0 los tramos 21:30–00:00 y 05:00–06:30 no tenían bloque y el demonio dormía hasta las 06:30.
## Entry point (línea 121-130)
## Entry point (línea 126-134)
```
if __name__ == "__main__":
1. logger_utilities.setup_logger()
2. logger_utilities.limpiar_logs_antiguos(dias=30)
1. logger_manager.setup_logging()
└── Configura logging a archivo rotado (logs/descarga_factura_transporte.log,
rotación diaria, 30 días de historial) + consola, y redirige print()/stderr
al mismo log (los módulos del pipeline ya usan logger con nivel explícito;
los print() restantes se registran como INFO).
2. logger.info(...) banner de inicio, incluyendo logger_manager.LOG_FILE
3. main()
└── Tras load_dotenv(), config_validator.verificar_configuracion():
termina con código 1 si falta alguna variable obligatoria del .env
(ver config_validator.OBLIGATORIAS) y advierte si la verificación SSL
está desactivada o no hay ruta de red.
```
+42
View File
@@ -0,0 +1,42 @@
# outlook_utilities.py — Microsoft Graph (Outlook)
Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación). Cómo obtener las credenciales: [configurar_msal.md](configurar_msal.md).
## Estructura
```
outlook_utilities.py
├── CONFIG (.env): CLIENT_ID, CLIENT_SECRET, TENANT_ID, VERIFY_SSL (false por defecto)
├── get_access_token() → str
├── get_emails(user_email, token, fecha_inicio, fecha_fin) → list[dict]
├── download_attachments(user_email, message_id, token, download_path) → list[str]
└── send_email(access_token, from_email, to_recipients, subject, html_body,
attachment_paths=None, cc_recipients=None) → bool
```
## Funciones
| Función | Endpoint Graph | Qué hace |
|---------|----------------|----------|
| `get_access_token` | `login.microsoftonline.com/{TENANT_ID}` (scope `.default`) | Obtiene el token de aplicación. Lanza `Exception` si MSAL devuelve error. |
| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach` y `es_automatico` (calculado con los encabezados del mensaje). Lanza `Exception` si el status no es 200. |
| `download_attachments` | `GET /users/{user}/messages/{id}/attachments` | Guarda en `download_path` los adjuntos tipo archivo y devuelve sus nombres. Crea la carpeta si no existe. |
| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos, CC y copia oculta (`bcc_recipients`); guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. |
## Zona horaria
`get_emails` asume UTC-5 (Perú, sin horario de verano): el rango `fecha_inicio`–`fecha_fin` se convierte a UTC sumando 5 h (el fin incluye todo el día), y el campo `fecha` devuelto resta 5 h a `receivedDateTime`.
## Adjuntos de `send_email`
Los adjuntos se leen de las rutas indicadas. Una ruta inexistente se omite con un `WARNING`. El `contentType` se deduce de la extensión (`.pdf`, `.xml`, `.json`; cualquier otra, `application/octet-stream`).
## Notas
- `download_attachments` solo guarda adjuntos tipo `fileAttachment` con contenido; los adjuntos de tipo mensaje o referencia se ignoran.
- Dos correos con un adjunto del mismo nombre se sobrescriben entre sí si comparten `download_path` (el pipeline usa una única carpeta, `DOWNLOAD_FOLDER`).
- `VERIFY_SSL` se respeta en `get_access_token`, `get_emails` y `download_attachments`, pero `send_email` tiene `verify_ssl = False` fijo y lo ignora.
- El módulo ejecuta `warnings.filterwarnings("ignore")`, lo que silencia todas las advertencias de Python del proceso (incluidas las de certificado SSL).
- `get_access_token` retorna `None` (sin lanzar error) si MSAL devuelve un resultado vacío.
- `es_mensaje_automatico(headers)` marca como automáticos los mensajes con `Auto-Submitted` distinto de `no`, `X-Auto-Response-Suppress`, `X-Autoreply`, `Precedence` (`bulk`, `junk`, `list`, `auto_reply`) o `Content-Type` de informe de entrega (rebotes). El sistema no responde a esos mensajes.
+140 -45
View File
@@ -4,12 +4,21 @@
```
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)
├── _procesar_un_correo() → None (orquesta los módulos de abajo)
└── run_pipeline() → None
Módulos que orquesta _procesar_un_correo():
├── adjuntos.py descargar_adjuntos, filtrar_y_limpiar
├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red
├── persistencia.py guardar_resultado, guardar_draft_json
├── draft_sap.py validate_prices, obtener_valor_referencial_sap, build_sap_draft
├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body (correo técnico)
├── observaciones.py motivos de rechazo con sus textos
├── respuesta_proveedor.py respuesta con plantilla al remitente (modo prueba/real, protecciones)
├── plantillas.py renderizador de las plantillas HTML
└── pipeline_utils.py log, ruta_carpeta
```
---
@@ -42,53 +51,93 @@ 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]
D --> E[3. descargar_adjuntos]
E --> F{Hay adjuntos?}
F -- No --> C
F -- Sí --> G[4. _descomprimir ZIPs]
G --> H[5. _filtrar_y_limpiar]
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]
I -- No --> RC[Rechazo del correo<br>responder al remitente]
I -- Sí --> J[6. procesar_pdfs]:::highlight
J --> K[7. procesar_xmls]:::highlight
K --> K2[8. completar_guias]
K2 --> K3[9. validar_guias]
K3 --> L{Hay documentos?}
L -- No --> RC
L -- Sí --> M[10. guardar_resultado]
M --> N[11. Filtrar facturas tipo 01]
N --> O{Hay facturas?}
O -- No --> C
O -- No --> RC
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]
P --> P2{11.5 ¿Ya existe en SAP?}
P2 -- Sí --> V
P2 -- No --> Q[12. validate_prices]:::highlight
Q --> Q2[12.5 obtener_valor_referencial_sap]
Q2 --> R[13. build_sap_draft]:::highlight
R --> S[14. guardar_draft_json]
S --> T{¿SAP/HANA caídos<br>u otro error?}
T -- Caída --> FL[estado fuera de línea:<br>aviso 'En cola' una vez<br>y el correo se reintenta]
T -- Otro error --> X[estado error]
T -- No --> T2{Todas coinciden?}
T2 -- Sí --> U[15. sapb1_manager.create_draft]
T2 -- No --> V[estado rechazado + observaciones]
U --> U2[estado aceptado]
U2 --> W[16. Correo técnico al equipo<br>+ respuesta con plantilla al remitente]
V --> W
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
FL --> P
W --> P
W2 --> P
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
`procesar_xmls` (paso 7) incluye internamente la copia de PDF/XML a la ruta de red (`copiar_a_red`, ver más abajo) por cada comprobante procesado.
---
## Funciones internas de `_procesar_un_correo`
## Funciones por módulo
| 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 |
> Son funciones de módulo (antes anidadas en `_procesar_un_correo`) y reciben explícitamente `email`, `download_folder`, `output_folder`, etc. `_procesar_un_correo` solo las orquesta.
| Función | Módulo | Propósito |
|---------|--------|-----------|
| `ruta_carpeta()` | `pipeline_utils` | Resuelve una ruta relativa al directorio del proyecto |
| `log(msg, level, last)` | `pipeline_utils` | Imprime con indentación de árbol |
| `copiar_a_red(data)` | `documentos` | Copia PDF/XML de un comprobante a la ruta de red configurable |
| `descargar_adjuntos(email, email_addr, token, download_folder)` | `adjuntos` | Descarga adjuntos vía Microsoft Graph |
| `filtrar_y_limpiar(email, download_folder)` | `adjuntos` | Filtra solo PDF/XML, elimina el resto |
| **`procesar_pdfs(email, download_folder)`** | `documentos` | **Extrae datos de PDFs, renombra, genera cache** |
| **`procesar_xmls(email, download_folder, pdf_cache)`** | `documentos` | **Procesa XMLs, cruza con PDF, asigna guías, copia a red** |
| `completar_guias(documentos, pdf_cache)` | `documentos` | Completa guías faltantes en líneas XML desde el PDF cache |
| `validar_guias(documentos)` | `documentos` | Advierte (log) guías con datos incompletos (falta tipo/serie/numero) |
| `guardar_resultado(documentos, datos_pdf, output_folder)` | `persistencia` | Guarda JSON con datos extraídos (XML y PDF) |
| `guardar_draft_json(draft, serie, numero, output_folder)` | `persistencia` | Guarda draft JSON para depuración |
| `calcular_fecha_vencimiento(fecha_base, condicion_pago)` | `draft_sap` | Calcula `DocDueDate` = fecha de contabilización + días de `PaymentTermsTypes` |
| **`build_sap_draft(factura, resultados_val)`** | `draft_sap` | **Construye payload del draft SAP B1 (incluye `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML`, `DocDueDate` por condición de pago)** |
| **`validate_prices(factura)`** | `draft_sap` | **Valida precios contra DeliveryNotes/Orders SAP** |
| `obtener_valor_referencial_sap(factura)` | `draft_sap` | Consulta `Sp_ObtenerValorReferencial` en HANA por cada guía única |
| `build_validation_html(resultados)` | `notificaciones` | Genera tabla HTML de validación de precios |
| `build_valor_referencial_html(factura, guias_valor_ref)` | `notificaciones` | Genera tabla HTML comparando valor referencial XML vs SAP |
| `build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | `notificaciones` | Arma cuerpo del email de notificación |
---
## `copiar_a_red(data)` — Copia a ruta de red
Copia el PDF y el XML de cada comprobante procesado a una ruta de red UNC configurable, en paralelo al guardado local existente (no lo reemplaza). No falla el pipeline si la ruta no está configurada o no está disponible: solo registra una advertencia.
- **Configuración** (`.env`): `RUTA_RED_FACTURACION` (ruta base UNC) y `RUC_EMPRESA` (RUC fijo de Aberio).
- **Estructura de destino**:
```
{RUTA_RED_FACTURACION}\{año}\{mes}\{día}\{tipo}\{RUC_EMPRESA}\{ruc_emisor}\
{RUC_EMPRESA}-{tipo}-{serie}-{numero}_FormatoImpreso.pdf
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
```
`RUC_EMPRESA` es el RUC de Aberio (fijo); `ruc_emisor` es el RUC del proveedor que emitió la factura (varía por comprobante). Año/mes/día salen de `fecha_emision`, sin ceros a la izquierda en mes/día.
- Guarda las rutas resultantes en `data["ruta_red_pdf"]` / `data["ruta_red_xml"]`, que luego usa `build_sap_draft` para llenar `U_VS_ANEXO_PDF` / `U_VS_ANEXO_XML` en el draft SAP B1.
- Se invoca desde `procesar_xmls`, justo después de resolver `data["ruta_pdf"]`.
---
@@ -96,19 +145,65 @@ flowchart TD
```
run_pipeline
├── database_utilities.init_db
├── step_authenticate
│ └── outlook_utilities.get_access_token
├── step_fetch_emails
│ └── outlook_utilities.get_emails
└── _procesar_un_correo (por cada email)
├── _descargar_adjuntos
├── database_utilities.get_existing_ids / save_emails
├── descargar_adjuntos
│ └── outlook_utilities.download_attachments
├── _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
│ └── email_utilities.descomprimir_adjuntos
├── filtrar_y_limpiar
├── procesar_pdfs
│ └── pdf_utilities.extraer_datos_pdf / renombrar_pdf
├── procesar_xmls
│ ├── sunat_schema.extraer_datos_xml
│ ├── pdf_utilities.extraer_referencias
│ └── copiar_a_red
├── completar_guias
├── validar_guias
├── guardar_resultado
│ └── sunat_schema.split_serie_numero, pdf_utilities.TIPO_MAP
├── (por cada factura tipo 01)
│ ├── validate_prices
│ │ ├── sapb1_manager.get_zonas
│ │ └── sapb1_manager.get_delivery_notes
│ │ ├── hana_manager.get_delivery (fallback si SL no responde;
│ │ │ busca en ODLN y, si no hay, en OWTR)
│ │ └── si el DocEntry vino de OWTR (StockTransfers), resuelve
│ │ además la Orden de Venta asociada (Orders vía
│ │ U_IDC_NUMOV) para poder validar las líneas FL*
│ ├── build_validation_html
│ ├── obtener_valor_referencial_sap
│ │ └── hana_manager.obtener_valor_referencial
│ ├── build_valor_referencial_html
│ ├── build_sap_draft
│ │ └── sapb1_manager.obtener_condicion_pago (BusinessPartners +
│ │ PaymentTermsTypes, para calcular DocDueDate)
│ ├── guardar_draft_json
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
│ ├── build_notification_body
│ ├── respuesta_proveedor.enviar_a_equipo (correo técnico, solo equipo)
│ └── respuesta_proveedor.responder (plantilla aceptado/rechazado, ver respuesta_proveedor.md)
```
---
## Qué se envía y a quién (paso 16)
| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
|-------------------------|-----------------------------------------------|-------------------------|
| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
| `rechazado` (precio, guía, o la factura **ya existe en SAP**) | Equipo | Rechazado con observaciones, al remitente |
| `fuera_de_linea` (SAP o HANA no responden) | Equipo (primer intento y al agotarse) | "En cola", **una sola vez**; el correo se reintenta |
| `error` (fallo inesperado, rechazo normal de SAP) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| Correo sin comprobante válido (sin PDF/XML, sin XML, sin factura) | — | Rechazado sin comprobante, al remitente |
| Correo sin adjuntos | — | Ninguna |
"Al remitente" depende de `NOTIFICACION_MODO` (en modo `prueba` va al equipo) y de las protecciones. Todo en [respuesta_proveedor.md](respuesta_proveedor.md).
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente. La lógica por factura vive en `_procesar_factura`; `_procesar_un_correo` la llama una vez por factura y, si alguna pide reintento, deja el correo en cola.
+2 -2
View File
@@ -2,8 +2,8 @@
## Ubicación
- **Archivo:** `pipeline.py` (línea 193-246)
- **Librería:** `pdf_utilities.py` (línea 1-450)
- **Archivo:** `pipeline.py` (línea 255-308, función anidada en `_procesar_un_correo`)
- **Librería:** `pdf_utilities.py` (línea 1-464)
---
+61 -39
View File
@@ -2,8 +2,8 @@
## Ubicación
- **Archivo:** `pipeline.py` (línea 248-370)
- **Librería:** `sunat_schema.py` (línea 1-520)
- **Archivo:** `pipeline.py` (línea 310-463, función anidada en `_procesar_un_correo`)
- **Librería:** `sunat_schema.py` (línea 1-720)
---
@@ -18,14 +18,19 @@ flowchart TD
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
E -- Sí --> CDR{Es CDR?}
CDR -- Sí --> CDR2[Solo renombrar, continue]
CDR2 --> B
CDR -- No --> G[Renombrar XML a nombre canónico]
G --> H[Construir nombre PDF canónico y buscarlo]
H --> RED[_copiar_a_red]:::highlight
RED --> I[Cruzar documentos_referencia desde PDF si faltan]:::highlight
I --> J[Asignar guías de remisión por línea]:::highlight
J --> K[Agregar a lista de documentos]
K --> B
B -- Fin --> L[return documentos]
B -- Fin --> L[Post-proceso: resolver GRE desde guía transportista referenciada]:::highlight
L --> M[return documentos]
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
@@ -46,45 +51,43 @@ _procesar_xmls(pdf_cache) → list[dict]
│ ├── xml_path = carpeta + adjunto
│ │
│ ├── sunat_schema.extraer_datos_xml(xml_path) → data | None
│ │ └── Solo procesa Invoice tipo 01 y DespatchAdvice tipo 31
│ │ └── Procesa Invoice tipo 01, DespatchAdvice tipo 09 y 31, y CDR (ApplicationResponse)
│ │
│ ├── ¿data es None? → ℹ️ XML sin datos, continue
│ │
│ ├── ¿data["es_cdr"]? → solo renombrar a "CDR-{ruc}-{tipo}-{serie}-{numero}.xml", continue
│ │
│ ├── data["ruta_xml"] = xml_path
│ │
│ ├── Construir nombre PDF canónico:
│ │ └── "{ruc_emisor}-{tipo}-{serie}-{numero}.pdf"
│ ├── Renombrar XML a nombre canónico:
│ │ └── "{ruc_emisor}-{tipo}-{serie}-{numero}.xml"
│ │
│ ├── Buscar PDF asociado:
│ │ ├── 1. pdf_cache por nombre canónico
│ │ └── 2. Fallback: mismo nombre con extensión .pdf
│ ├── Construir nombre PDF canónico y buscar el PDF asociado:
│ │ ├── 1. Mismo nombre canónico con extensión .pdf
│ │ └── 2. Fallback: mismo nombre que el XML renombrado, con extensión .pdf
│ │ └── data["ruta_pdf"] = ruta encontrada o None
│ │
│ ├── _copiar_a_red(data, _log)
│ │ └── Copia PDF/XML a la ruta de red (RUTA_RED_FACTURACION), si está configurada.
│ │ Guarda data["ruta_red_pdf"] / data["ruta_red_xml"] (ver docs/pipeline.md).
│ │
│ ├── ¿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
│ ├── Asignar guías de remisión y transportista por línea (ver prioridad abajo)
│ │
│ └── ✅ Agregar data a documentos[]
│
├── Post-procesamiento — resolver GRE desde guía de transporte referenciada:
│ ├── guia_lookup = {(tipo, serie, numero): doc} para cada documento tipo 09/31 del correo
│ └── Por cada factura (tipo 01), por cada documento_referencia tipo 31:
│ ├── Buscar la guía transportista en guia_lookup
│ ├── Tomar sus documentos_referencia tipo 09 (GRE) propios
│ └── Asignarlos a las líneas de la factura que aún no tengan guias_remision
│
└── return documentos
```
@@ -99,11 +102,14 @@ flowchart TD
A[extraer_datos_xml] --> B[ET.parse xml]
B --> C{Tag raíz?}
C -- ApplicationResponse --> CDR[_parse_application_response]:::highlight
CDR --> I2[return dict con es_cdr=True]
C -- Invoice --> D{tipo == 01?}
D -- Sí --> E[_parse_invoice]:::highlight
D -- No --> F[return None]
C -- DespatchAdvice --> G{tipo == 31?}
C -- DespatchAdvice --> G{tipo en 09, 31?}
G -- Sí --> H[_parse_despatch_advice]:::highlight
G -- No --> F
@@ -115,7 +121,7 @@ flowchart TD
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
Solo procesa **Facturas tipo 01** y **Guías de Remisión Transportista tipo 31**.
Procesa **Facturas tipo 01**, **Guías de Remisión Remitente tipo 09**, **Guías de Remisión Transportista tipo 31**, y **CDR** (`ApplicationResponse`, solo para renombrar el archivo, no se agrega a `documentos[]`).
---
@@ -164,10 +170,14 @@ flowchart TD
| `cantidad` | `cbc:InvoicedQuantity` |
| `precio_unitario` | `cac:PricingReference/cac:AlternativeConditionPrice/cbc:PriceAmount` o `cac:Price/cbc:PriceAmount` |
| `total_linea` | `cbc:LineExtensionAmount` |
| `guias_remision[]` / `guia_transportista` | Parseadas desde `cbc:Note` de la línea (`extraer_guias_desde_notes`) — ver prioridad de asignación más abajo |
| `valor_referencial` | Extraído de la descripción del ítem, del `Note` de la línea, o de los `Note` a nivel de documento (`extraer_valor_referencial`) |
---
### `_parse_despatch_advice(root)` — Guía tipo 31
### `_parse_despatch_advice(root)` — Guías tipo 09 y 31
La misma función procesa tanto la Guía de Remisión Remitente (09) como la Guía de Remisión Transportista (31); el `tipo` viene de `DespatchAdviceTypeCode`.
| Campo | Tag |
|-------|-----|
@@ -188,7 +198,7 @@ flowchart TD
| `ubigeo_partida` | `Delivery/Despatch/DespatchAddress/ID` |
| `lineas[]` | `DespatchLine` con `DeliveredQuantity` y `Item/Description` |
Documentos de referencia desde `AdditionalDocumentReference`.
Documentos de referencia desde `AdditionalDocumentReference` (para la guía transportista, típicamente referencian la GRE — guía remitente — asociada).
---
@@ -205,15 +215,22 @@ pdf_cache[nombre_canonico].items[] → extraer_referencias()
└── guia_transportista → ref tipo 31
```
### 2. Asignar guías por línea
### 2. Asignar guías por línea (dentro de `_procesar_xmls`)
Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea:
Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea de la factura, en el orden en que realmente se aplican en el código:
| 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` |
| 1ª | `notes_text` (ya resuelto en `sunat_schema._parse_invoice`) | Si la línea ya trae `guias_remision`/`guia_transportista` extraídos del `cbc:Note` del XML (vía `extraer_guias_desde_notes`), se conservan tal cual. |
| 2ª | `documentos_referencia` (`refs_09`/`refs_31`) | Si la línea no trajo guías desde `notes_text`, se asigna posicionalmente desde `documentos_referencia` (`refs_09[i]`, `refs_31[i]`; la última línea recibe todas las refs restantes). |
### 3. Post-procesamiento — resolver GRE desde guía de transporte referenciada
Después de procesar todos los XML del correo, si una factura referencia una **guía transportista (31)** y esa guía (presente como documento independiente en el mismo correo) referencia a su vez una o más **GRE (09)**, esas GRE se propagan a las líneas de la factura que aún no tengan `guias_remision` asignadas.
### 4. `_completar_guias` (paso posterior, fuera de `_procesar_xmls`)
Si tras los pasos anteriores alguna línea sigue sin `guias_remision`, `_completar_guias` (ver `docs/pipeline.md`) las copia desde los `items[]` del PDF cache, parseando el texto con regex (`{T/V/E}{serie}-{numero}`).
---
@@ -246,12 +263,17 @@ Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea:
"precio_unitario": 1.3396,
"total_linea": 468.86,
"guias_remision": [{"tipo": "09", "serie": "T001", "numero": "127773"}],
"guia_transportista": None
"guia_transportista": None,
"valor_referencial": 468.86
}
],
"ruta_xml": "/path/to/20537929520-01-F002-486.xml",
"ruta_pdf": "/path/to/20537929520-01-F002-486.pdf",
"ruta_red_pdf": "\\\\192.168.9.66\\b1_shf\\Facturacion Electronica\\PRODUCCION\\2026\\7\\8\\01\\20100029741\\20537929520\\20100029741-01-F002-486_FormatoImpreso.pdf",
"ruta_red_xml": "\\\\192.168.9.66\\b1_shf\\Facturacion Electronica\\PRODUCCION\\2026\\7\\8\\01\\20100029741\\20537929520\\20100029741-01-F002-486.xml",
"tipo_descripcion": "Factura"
}
]
```
`ruta_red_pdf`/`ruta_red_xml` solo aparecen si `RUTA_RED_FACTURACION` está configurada en `.env` y la copia fue exitosa; se usan luego en `_build_sap_draft` para llenar `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML` (ver `docs/pipeline.md`).
+115
View File
@@ -0,0 +1,115 @@
# respuesta_proveedor.py — Respuesta al proveedor
Cuando alguien envía un correo con el XML y el PDF de una factura, el sistema valida y **responde a esa misma dirección** con la plantilla de **aceptado** o de **rechazado** (`plantilla/plantilla-aceptado.html` y `plantilla-rechazado.html`). Aparte, el equipo recibe un correo técnico (tablas de SAP, JSON adjuntos).
Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observaciones.py` (motivos de rechazo y sus textos), `plantillas.py` (renderizador) y `seguimiento.py` (código de seguimiento y registro).
## Qué se responde
| Situación | Respuesta al remitente |
|-----------|------------------------|
| Draft creado en SAP | **Aceptado** |
| Precios que no coinciden, guía inexistente, línea sin guía, cantidad cero | **Rechazado** con una observación por motivo |
| La factura **ya existe en SAP** (factura de compra no anulada o draft) | **Rechazado**: "Esta factura ya está registrada". No se crea otro draft |
| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
| **SAP o HANA no responden** | **Aviso "En cola"** (una sola vez): se reintentará solo, no hace falta reenviar. Ver más abajo |
| Fallo inesperado, o un rechazo normal de SAP al crear el draft (HTTP 4xx) | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| Correo sin adjuntos | **Nada** |
Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
> **Guía "no encontrada" y caídas.** `get_delivery_notes` ya no confunde una caída con una guía inexistente: si Service Layer no responde y HANA no aporta nada, lanza `SapNoDisponible`. Además, antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, es "servicio fuera de línea" y no un rechazo.
## ¿La factura ya existe en SAP?
Antes de validar precios, `sapb1_manager.buscar_factura_existente` busca el comprobante por proveedor (`PL` + RUC), tipo, serie y número (`U_BPP_MDTD`/`U_BPP_MDSD`/`U_BPP_MDCD`) en las **facturas de compra no anuladas** y en los **drafts** de factura de compra. Si existe, no se crea otro draft y se responde con la observación `factura_ya_registrada`. Si el comprobante ya estaba **aceptado** en `comprobantes`, ese registro (estado y `doc_entry`) no se sobrescribe.
> La plantilla de rechazo dice "No pudimos registrar su factura… Corríjalas y envíenos el comprobante nuevamente", que no encaja del todo con un duplicado: la observación aclara que no hace falta reenviarla. Si prefiere una plantilla propia para este caso, es un cambio pequeño.
## Servicio fuera de línea y reintentos
`SapNoDisponible` (subclase de `ConnectionError`) se lanza solo ante una **caída**: timeout, sin conexión, HTTP 5xx o login imposible por esos motivos. Un HTTP 4xx (rechazo de SAP) o credenciales inválidas siguen siendo un `ConnectionError` normal y **no** se tratan como caída.
Cuando una factura se encuentra con una caída:
1. Se suma un intento en `comprobantes.intentos` y el resultado queda `error` con la observación `servicio_no_disponible`.
2. **Solo en el primer intento** se envía al proveedor la plantilla `plantilla-fuera-de-linea.html` ("En cola, la procesaremos automáticamente, no necesita reenviarla") y se avisa al equipo. La notificación queda `en_espera`. Los reintentos siguientes **no repiten** ningún aviso.
3. El correo se **elimina de `emails`**, de modo que el siguiente ciclo del demonio lo vuelve a tomar.
4. Cuando SAP vuelve, se procesa con normalidad y el proveedor recibe el aceptado o rechazado.
5. Tras `MAX_REINTENTOS` (24) intentos se deja de reintentar, se avisa al equipo ("Reintentos agotados") y el proveedor no recibe nada más.
Al reintentar un correo, lo que ya se resolvió dentro de él (otra factura del mismo correo) no se repite ni se responde dos veces. Sin seguimiento (si falla la base de datos) no se puede contar intentos, así que una caída se trata como error interno y no se reintenta.
## Modo prueba y modo real
`NOTIFICACION_MODO` en el `.env`:
| Modo | A quién va la respuesta | Asunto |
|------|-------------------------|--------|
| `prueba` (**por defecto**) | Al equipo (`NOTIFICACION_EQUIPO`), **nunca** al remitente | `[PRUEBA → remitente] Aceptado · Factura F002-486 · ABR-…` |
| `real` | Al remitente, con **copia oculta** al equipo | `Aceptado · Factura F002-486 · ABR-…` |
Cualquier otro valor se trata como `prueba`. Al arrancar, `config_validator` deja en el log una advertencia con el modo activo. Pasar a `real` es una decisión explícita: no ocurre solo.
## Protecciones: cuándo NO se responde
El sistema contesta solo y a terceros, así que no responde a:
| Caso | Motivo registrado |
|------|-------------------|
| Remitente vacío o desconocido | remitente desconocido |
| Dirección sin dominio válido (por ejemplo `test@local`) | dirección no válida |
| El propio buzón del sistema | el remitente es el propio buzón |
| `no-reply`, `noreply`, `do-not-reply`, `no-responder`, `mailer-daemon`, `postmaster`, `bounce` | dirección automática |
| Mensajes automáticos (respuestas automáticas, rebotes, listas) | mensaje automático |
| En correos **sin comprobante**: asunto con `Re:`/`RV:` o que cita un código `ABR-…` | parece una respuesta a una notificación nuestra |
| En correos sin comprobante: más de 3 respuestas al mismo remitente en 24 h | límite de respuestas |
Cada omisión queda en `comprobantes` como `notificacion_estado = omitida`, con el motivo.
## Qué ve el proveedor
- **Importe por guía:** el importe de la línea aparece una vez, en su primera guía; las demás dicen "incluida en la línea". Todas las guías de una línea llevan el mismo resultado, porque la validación es por línea.
- **Rechazo por precio:** se muestran "Importe en su factura" e "Importe esperado" (el de SAP).
- **Pago estimado:** solo si hay condición de pago (fecha de vencimiento posterior a la de contabilización).
- **Sin adjuntos internos:** la respuesta no lleva archivos.
## Observaciones (`observaciones.py`)
Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar la lógica.
| Código | Cuándo |
|--------|--------|
| `precio_no_coincide` | El total de la línea del XML no coincide con la suma de SAP |
| `guia_no_encontrada` | SAP no devuelve datos para la guía |
| `linea_sin_guia` | Una línea no indica su guía de remisión |
| `cantidad_cero` | Una línea tiene cantidad cero |
| `factura_ya_registrada` | La factura ya existe en SAP |
| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
| `falta_xml` | Hay PDF y ningún XML |
| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
## Configuración (`.env`)
| Variable | Para qué | Por defecto |
|----------|----------|-------------|
| `NOTIFICACION_MODO` | `prueba` o `real` | `prueba` |
| `NOTIFICACION_EQUIPO` | Direcciones internas (coma o `;`): correo técnico, y respuestas en modo prueba | `jlopez@aberio.com.pe`, `gcalle@pucp.pe` |
| `NOTIFICACION_CONTACTO` | Dirección del botón "Escribir a Aberio" | el buzón `EMAIL` |
| `LOGO_URL` | URL pública (https) del logo | vacío: sin logo |
El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el comprobante corregido.
## Cómo probar
1. **Vista previa y envío de ejemplos:** `python tools/previsualizar_plantillas.py` (ver su ayuda). Los ejemplos están en `plantilla/ejemplos.json`.
2. **Piloto en modo prueba:** con `NOTIFICACION_MODO=prueba` el flujo completo corre y las respuestas llegan solo al equipo, indicando en el asunto a quién habrían ido. El correo técnico también.
3. **Pasar a real:** cuando el equipo valide los correos, cambiar a `NOTIFICACION_MODO=real`. Revisar antes que `LOGO_URL` tenga valor.
## Limitaciones conocidas
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado. (Solo se reintentan los correos cuando SAP está caído.)
- Los reintentos de SAP caído dependen de que el correo siga dentro de la ventana `--dias` del demonio (2 días por defecto).
- Un error que no es una caída (por ejemplo un HTTP 400 de SAP al crear el draft) no se reintenta: se avisa al equipo.
- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
+67
View File
@@ -0,0 +1,67 @@
# seguimiento.py — Códigos de seguimiento y resultado por comprobante
Cada comprobante que llega por correo recibe un **código de seguimiento** (`ABR-AAMMDD-NNNNN`, por ejemplo `ABR-261019-00042`) que se le comunicará al proveedor y que permite reconocer el comprobante si lo reenvía. El registro también guarda el resultado del proceso y el estado de la notificación.
Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `database_utilities.init_db()` con `CREATE TABLE IF NOT EXISTS`, por lo que una base existente se actualiza sola sin tocar la tabla `emails`.
## Identidad
- Un comprobante se identifica por **(RUC emisor, tipo, serie, número)**. Si vuelve a llegar, en el mismo correo o en otro, conserva su código.
- Un correo que no trae ningún comprobante identificable (sin adjuntos, sin XML válido…) se registra aparte con `registrar_correo`: una fila por correo, con RUC, serie y número en `NULL`.
- El código sale del `id` de la fila (`ABR-` + fecha + `id` con 5 dígitos), así que es único por construcción, sin reintentos ni contadores aparte.
## Tabla `comprobantes`
| Columna | Contenido |
|---------|-----------|
| `codigo_seguimiento` | Código único del comprobante |
| `email_id` | Último correo en el que llegó |
| `ruc_emisor`, `tipo`, `serie`, `numero` | Identidad del comprobante (`NULL` en las filas de correo sin comprobante) |
| `estado` | `pendiente`, `aceptado`, `rechazado` o `error` |
| `observaciones` | JSON con los motivos de rechazo o el detalle del error |
| `doc_entry` | DocEntry del draft creado en SAP |
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida`, `omitida` o `en_espera` (se avisó que el servicio está fuera de línea), y su motivo |
| `intentos` | Veces que se intentó procesar el comprobante con SAP fuera de línea (tope: `MAX_REINTENTOS`) |
| `notificacion_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) |
| `creado_en`, `actualizado_en` | Marcas de tiempo |
## Funciones
| Función | Qué hace |
|---------|----------|
| `registrar_comprobante(email_id, ruc_emisor, tipo, serie, numero)` | Crea el registro o recupera el existente. Devuelve la fila más `reenvio` (True si ya había llegado en **otro** correo). Reprocesar el mismo correo no cambia nada. |
| `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. |
| `obtener(codigo)` | Devuelve el registro o `None`. |
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. |
| `registrar_intento(codigo)` | Suma un intento fallido por SAP fuera de línea y devuelve el total. |
| `contar_respuestas_correo(remitente, horas=24)` | Respuestas `enviada` a ese remitente en correos sin comprobante: el freno contra bucles de `respuesta_proveedor`. |
## Uso en el pipeline
En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_registrar_comprobante`) y su resultado se guarda al terminar (`_registrar_resultado`):
| Resultado del proceso | `estado` | Detalle guardado |
|-----------------------|----------|------------------|
| Draft creado en SAP | `aceptado` | `doc_entry` |
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
`init_db` agrega las columnas `notificacion_destinatario` e `intentos` a una tabla creada antes sin ellas, sin tocar los datos.
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
## Observaciones guardadas
- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
- Error: `[{"codigo": "error_interno", "detalle": "..."}]`.
- SAP fuera de línea: `[{"codigo": "servicio_no_disponible", "detalle": "...", "intentos": n}]`.
- Correo sin comprobante: la observación del motivo (`sin_pdf_xml`, `falta_xml`, `sin_factura`).
Las notificaciones las registra `respuesta_proveedor` (`enviada`, `fallida` u `omitida` con su motivo).
## Notas
- `database_utilities.DB_NAME` es `emails.db` **junto al código** (ruta absoluta): no depende del directorio desde el que se lance el demonio, por ejemplo como servicio de Windows.
- Las pruebas redirigen `DB_NAME` a una base temporal (fixture automático en `tests/conftest.py`) para no escribir en `emails.db`.
+50
View File
@@ -0,0 +1,50 @@
# Validar Precios
```mermaid
flowchart TD
A["factura con lineas[]"] --> B["Obtener maestro zonas<br>SAP U_IDC_ZONAS"]
B --> C["Por cada línea de factura"]
C --> D{"cantidad > 0?"}
D -->|No| E["Resultado: cantidad cero, sin validar"]
D -->|Sí| F{"¿Hay guías de remisión?"}
F -->|No| G["Resultado: sin guía de referencia"]
F -->|Sí| H["Por cada guía en guias[]"]
H --> I["Consultar SAP B1<br>get_delivery_notes()"]
I --> I2{"¿Guía encontrada en<br>ODLN o en OWTR?"}
I2 -->|ODLN| I3["Documento = DeliveryNotes(DocEntry)"]
I2 -->|OWTR| I4["Documento = StockTransfers(DocEntry)<br>→ leer U_IDC_NUMOV<br>→ Documento = Orders(U_IDC_NUMOV)"]
I3 --> J["Por cada documento<br>(DeliveryNote u Orden de Venta)"]
I4 --> J
J --> K["Obtener zona desde<br>U_IDC_DESTINO"]
K --> L["Extraer WarehouseCode<br>de DocumentLines[0]"]
L --> M["Acumular Weight1<br>de DocumentLines[0]"]
M --> N["Por cada línea FL* en<br>DocumentLines"]
N --> O["Sumar LineTotal"]
O --> P["Máx Quantity"]
P --> Q["Sumar PriceAfterVAT"]
Q --> R["Construir guias_lt[]<br>para cada guía:<br>line_total, quantity,<br>price_after_vat_sum,<br>warehouse_code, zona, weight1"]
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
S --> T{"importes_coinciden:<br>diferencia redondeada a 2 decimales ≤ 0.01<br>y ninguna guía falló?"}
T -->|Sí| U["ok = True<br>draft_quantity = int(Quantity máx FL*)<br>draft_price = suma PriceAfterVAT FL*"]
T -->|No| V["ok = False<br>draft_quantity = int(cantidad XML)<br>draft_price = total_linea / cantidad"]
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>label, warehouse_code, guias_lt[]"]
V --> W
W --> C
C --> X["todas_coinciden = all(ok)"]
```
## Notas
- Si la consulta a SAP de una guía lanza error, esa guía queda con ceros y la línea completa se marca `ok = False` (se registra como `WARNING`).
- Si ninguna guía de la línea existe en SAP, el resultado lleva `label = "Sin guías SAP"` y se registra "línea saltada", pero igual queda `ok = False` (salvo que `total_linea` sea 0), por lo que `todas_coinciden` es falso y no se crea el draft.
- `weight1` (peso de la primera línea de cada guía) se sigue calculando y guardando en `guias_lt[]`, pero el draft ya no lo usa.
- Función: `draft_sap.validate_prices(factura)` → `(todas_coinciden, resultados)`.
- **Tolerancia:** `pipeline_utils.importes_coinciden` redondea la diferencia entre el importe de la línea del XML y el de SAP a 2 decimales (lo que muestra un comprobante) y admite hasta 1 céntimo. Comparar la diferencia sin redondear (`abs(a - b) < 0.01`) no era fiable por la coma flotante: 431.41 − 431.40 daba `0.01000000000004775` (se rechazaba) y 624.39 − 624.38 daba `0.00999999999999091` (se aceptaba), así que el mismo céntimo pasaba o no según el importe. Con datos reales de SAP, ninguna línea cambió de resultado al introducir el redondeo. Se usa también para comparar el valor referencial del correo técnico.
-37
View File
@@ -1,37 +0,0 @@
# Validar Precios
```mermaid
flowchart TD
A["factura con lineas[]"] --> B["Obtener maestro zonas<br>SAP U_IDC_ZONAS"]
B --> C["Por cada línea de factura"]
C --> D{"cantidad > 0?"}
D -->|No| E["Resultado: cantidad cero, sin validar"]
D -->|Sí| F{"¿Hay guías de remisión?"}
F -->|No| G["Resultado: sin guía de referencia"]
F -->|Sí| H["Por cada guía en guias[]"]
H --> I["Consultar SAP B1<br>get_delivery_notes()"]
I --> J["Por cada DeliveryNote"]
J --> K["Obtener zona desde<br>U_IDC_DESTINO"]
K --> L["Extraer WarehouseCode<br>de DocumentLines[0]"]
L --> M["Acumular Weight1<br>de DocumentLines[0]"]
M --> N["Por cada línea FL* en<br>DocumentLines"]
N --> O["Sumar LineTotal"]
O --> P["Máx Quantity"]
P --> Q["Sumar PriceAfterVAT"]
Q --> R["Construir guias_lt[]<br>para cada guía:<br>line_total, quantity,<br>price_after_vat_sum,<br>warehouse_code, zona, weight1"]
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
S --> T{"abs(diff) < 0.01?"}
T -->|Sí| U["ok = True<br>use_sap_prices si qty==1<br>y total_sap_qty≠1"]
T -->|No| V["ok = False"]
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>draft_cantidad, guias_lt[]"]
V --> W
W --> C
C --> X["todas_coinciden = all(ok)"]
```
+378
View File
@@ -0,0 +1,378 @@
"""Extracción de datos de PDF/XML y asignación de guías de remisión."""
import os
import logging
import re
import shutil
import ntpath
from datetime import datetime
import sunat_schema
import pdf_utilities
from pipeline_utils import log, ruta_carpeta
# ============================================================================
def copiar_a_red(data: dict) -> None:
"""
Copia el PDF y el XML de un comprobante ya procesado a la ruta de red
de Facturación Electrónica, organizada como::
{RUTA_RED_FACTURACION}\\{año}\\{mes}\\{día}\\{tipo}\\{RUC_EMPRESA}\\{ruc_emisor}\\
{RUC_EMPRESA}-{tipo}-{serie}-{numero}_FormatoImpreso.pdf
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
``RUC_EMPRESA`` es el RUC de Aberio (fijo, por ahora); ``ruc_emisor``
es el RUC del proveedor que emitió la factura (varía por comprobante).
Año/mes/día se toman de ``fecha_emision`` (mes y día sin ceros a la
izquierda). No falla el pipeline si la ruta de red no está configurada
o no está disponible: solo registra una advertencia.
Guarda las rutas resultantes en ``data["ruta_red_pdf"]`` y
``data["ruta_red_xml"]`` para que ``build_sap_draft`` las incluya en
el draft (``U_VS_ANEXO_PDF`` / ``U_VS_ANEXO_XML``).
"""
RUTA_RED_FACTURACION = os.getenv("RUTA_RED_FACTURACION", "")
RUC_EMPRESA = os.getenv("RUC_EMPRESA", "")
if not RUTA_RED_FACTURACION:
return
if not os.path.isabs(RUTA_RED_FACTURACION):
# Fuera de Windows una ruta UNC (\\\\servidor\\recurso) no es absoluta: makedirs la crearía como una carpeta
# local con ese nombre, y el draft apuntaría a archivos que no están en el recurso compartido.
log("⚠️ RUTA_RED_FACTURACION no es una ruta absoluta en este sistema operativo: no se copia a la red", 3,
severity=logging.WARNING)
return
ruc_emisor = data.get("ruc_emisor", "")
tipo = data.get("tipo", "")
serie = data.get("serie", "")
numero = data.get("numero", "")
xml_path = data.get("ruta_xml")
pdf_path = data.get("ruta_pdf")
try:
fecha = datetime.strptime(data.get("fecha_emision", ""), "%Y-%m-%d")
except (ValueError, TypeError):
log(f"⚠️ No se pudo copiar a red: fecha_emision inválida ({data.get('fecha_emision')})", 3, severity=logging.WARNING)
return
carpeta_destino = os.path.join(
RUTA_RED_FACTURACION,
str(fecha.year), str(fecha.month), str(fecha.day),
tipo, RUC_EMPRESA, ruc_emisor,
)
# Ruta guardada en el draft SAP (U_VS_ANEXO_PDF/XML): siempre estilo
# Windows/UNC con backslash, sin importar el separador del SO donde
# corre el script (evita mezclar "\\" y "/" al usar os.path.join en
# un host no-Windows).
carpeta_destino_unc = ntpath.join(
RUTA_RED_FACTURACION,
str(fecha.year), str(fecha.month), str(fecha.day),
tipo, RUC_EMPRESA, ruc_emisor,
)
nombre_base = f"{RUC_EMPRESA}-{tipo}-{serie}-{numero}"
try:
os.makedirs(carpeta_destino, exist_ok=True)
if pdf_path and os.path.exists(pdf_path):
ruta_red_pdf = os.path.join(carpeta_destino, f"{nombre_base}_FormatoImpreso.pdf")
shutil.copy2(pdf_path, ruta_red_pdf)
data["ruta_red_pdf"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}_FormatoImpreso.pdf")
if xml_path and os.path.exists(xml_path):
ruta_red_xml = os.path.join(carpeta_destino, f"{nombre_base}.xml")
shutil.copy2(xml_path, ruta_red_xml)
data["ruta_red_xml"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}.xml")
log(f"📁 Copiado a red: {carpeta_destino}", 3)
except OSError as e:
log(f"⚠️ Error copiando a ruta de red: {e}", 3, severity=logging.WARNING)
# ============================================================================
def procesar_pdfs(email: dict, download_folder: str) -> dict:
"""
Procesa todos los PDF de los adjuntos del correo.
Por cada PDF:
1. Extrae sus datos estructurados via ``pdf_utilities.extraer_datos_pdf``.
2. Renombra el archivo al formato canónico
``{ruc}-{tipo}-{serie}-{numero}.pdf``.
3. Almacena los datos extraídos en un cache indexado por el nombre
canónico.
Los datos extraídos también se acumulan en ``email["datos_pdf"]``.
Returns:
dict — Cache ``{nombre_canonico.pdf: datos_extraídos}``.
Example:
Si el PDF es ``F002-486.pdf`` y la extracción devuelve
``ruc_emisor=20537929520, tipo_documento=FACTURA``::
cache = procesar_pdfs(email, download_folder)
# → {"20537929520-01-F002-486.pdf": {...datos...}}
# El archivo se renombra a 20537929520-01-F002-486.pdf
"""
carpeta = ruta_carpeta(download_folder)
pdf_cache: dict[str, dict] = {}
datos_pdf = []
for idx, adjunto in enumerate(email.get("adjuntos", [])):
if not adjunto.lower().endswith(".pdf"):
continue
pdf_path = os.path.join(carpeta, adjunto)
data = pdf_utilities.extraer_datos_pdf(pdf_path)
if data and not data.get("error"):
nuevo_path = pdf_utilities.renombrar_pdf(pdf_path, data)
if nuevo_path != pdf_path:
nuevo_nombre = os.path.basename(nuevo_path)
email["adjuntos"][idx] = nuevo_nombre
data["archivo"] = nuevo_nombre
datos_pdf.append(data)
canonical = os.path.basename(nuevo_path)
pdf_cache[canonical] = data
log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
else:
log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2, severity=logging.WARNING)
if datos_pdf:
email["datos_pdf"] = datos_pdf
return pdf_cache
# ============================================================================
def procesar_xmls(email: dict, download_folder: str, pdf_cache: dict) -> list[dict]:
"""
Procesa todos los XML de los adjuntos del correo.
Por cada XML:
1. Extrae datos estructurados via ``sunat_schema.extraer_datos_xml``.
2. Construye el nombre canónico del PDF asociado
(``{ruc}-{tipo}-{serie}-{numero}.pdf``) y lo busca en el cache.
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 desde
``notes_text`` o ``documentos_referencia``.
Returns:
list[dict] — Lista de documentos extraídos listos para
``guardar_resultado``.
Example:
XML con ``DespatchDocumentReference`` ID sucio
``"GRRE: T001-127773 / GRET :EG03-11"``::
docs = procesar_xmls(email, download_folder, pdf_cache)
# → [{
# "lineas": [{
# "guias_remision": [{"tipo":"09","serie":"T001","numero":"127773"}],
# "guia_transportista": {"tipo":"31","serie":"EG03","numero":"11"}
# }]
# }]
"""
carpeta = ruta_carpeta(download_folder)
documentos = []
last_idx = len(email["adjuntos"]) - 1
for idx, a in enumerate(email["adjuntos"]):
log(a, 2, last=(idx == last_idx))
if not a.lower().endswith(".xml"):
continue
xml_path = os.path.join(carpeta, a)
log(f"📄 Procesando XML: {a}", 3)
data = sunat_schema.extraer_datos_xml(xml_path)
if not data:
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
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):
# Fallback: reemplazar extensión .xml por .pdf
pdf_path = os.path.splitext(xml_path)[0] + ".pdf"
pdf_path = pdf_path if os.path.exists(pdf_path) else None
data["ruta_pdf"] = pdf_path
copiar_a_red(data)
# Cruzar documentos_referencia desde el PDF renombrado (pdf_cache por nombre canónico)
if not data.get("documentos_referencia"):
pdf_data = pdf_cache.get(pdf_canonical)
if pdf_data:
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
if refs:
data["documentos_referencia"] = 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
log(f"🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)", 3)
break
# 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"]
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 completar_guias(documentos: list[dict], pdf_cache: dict) -> int:
"""
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, severity=logging.WARNING)
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, severity=logging.WARNING)
+445
View File
@@ -0,0 +1,445 @@
"""Validación de precios contra SAP y construcción del draft SAP B1."""
import re
import logging
import math
from datetime import datetime, timedelta
import sapb1_manager
import hana_manager
from pipeline_utils import importes_coinciden, log
# ============================================================================
def hay_error_de_consulta(resultados_val: list[dict]) -> str | None:
"""
Devuelve el detalle del primer error de consulta a SAP que dejó ``validate_prices``
(una guía cuya consulta lanzó excepción), o None si no hubo ninguno. Un error de
consulta es una falla nuestra: no debe confundirse con una guía inexistente.
"""
for resultado in resultados_val:
for item in resultado.get("guias_lt", []):
if item.get("error"):
return item["error"]
return None
def hana_disponible() -> bool:
"""
True si se puede abrir una conexión a SAP HANA. ``get_delivery_notes`` devuelve ``[]``
tanto si una guía no existe como si HANA está caído; antes de decirle a un proveedor
que una guía "no se encontró" conviene descartar que haya sido una caída.
"""
conexion = hana_manager.get_connection()
if conexion is None:
return False
try:
conexion.close()
except Exception:
pass
return True
def calcular_fecha_vencimiento(fecha_base: str, condicion_pago: dict | None) -> str:
"""
Calcula la fecha de vencimiento sumando a ``fecha_base`` los días de
la condición de pago (PaymentTermsTypes) del proveedor.
Usa ``NumberOfAdditionalDays`` como días a sumar; si ese valor es 0,
usa ``NumberOfAdditionalMonths * 30`` en su lugar.
Args:
fecha_base: Fecha de contabilización, formato "YYYY-MM-DD".
condicion_pago: Resultado de ``sapb1_manager.obtener_condicion_pago``
(campos ``NumberOfAdditionalDays``, ``NumberOfAdditionalMonths``),
o None si el proveedor no tiene condición de pago configurada.
Returns:
str — Fecha de vencimiento, formato "YYYY-MM-DD". Si
``condicion_pago`` es None, retorna ``fecha_base`` sin cambios.
"""
fecha = datetime.strptime(fecha_base, "%Y-%m-%d")
if not condicion_pago:
return fecha.strftime("%Y-%m-%d")
dias = int(condicion_pago.get("NumberOfAdditionalDays") or 0)
if dias == 0:
dias = int(condicion_pago.get("NumberOfAdditionalMonths") or 0) * 30
fecha += timedelta(days=dias)
return fecha.strftime("%Y-%m-%d")
# ============================================================================
def build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
"""
Construye el payload JSON para crear un Draft en SAP B1 Service Layer
(endpoint ``POST /Drafts``).
Flujo:
1. Toma los datos de cabecera de la factura (RUCS, fechas, totales).
2. Determina el ``ItemCode`` según la fecha de emisión:
``C103002-A`` si es de un mes anterior, ``C103002`` si es del
mes actual.
3. Prepara las cuotas de detracción (``DocumentInstallments``):
si la factura tiene detracción, genera dos cuotas (detracción y
neto); si no, una sola cuota por el total.
4. Por cada línea de factura, itera sobre las guías validadas
(``guias_lt[]`` de ``resultados_val``) y genera una línea de
documento (``DocumentLine``) por cada guía con:
- ``ItemCode``, ``Quantity``, ``PriceAfterVAT``,
- ``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 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
XML (claves: ``serie``, ``numero``, ``tipo``, ``lineas[]``,
``guias_remision[]``, ``tiene_detraccion``, etc.).
resultados_val: Lista de resultados de ``validate_prices``,
uno por línea de factura. Cada elemento contiene
``guias_lt[]``, ``ok``, etc.
Returns:
Dict con la estructura completa del draft SAP B1 lista para
enviar a ``POST /Drafts``.
Example:
Factura F002-486 con guía T001-127773 y SAP devuelve
``FL10007 qty=350 pav=1.3396``::
draft = build_sap_draft(factura, resultados_val)
# → {
# "DocType": "dDocument_Items",
# "CardCode": "PL20537929520",
# "DocumentLines": [{
# "ItemCode": "C103002",
# "Quantity": 350,
# "PriceAfterVAT": 1.3396,
# "U_IDC_GRFLETE": "09T001-127773",
# ...
# }],
# "DocumentInstallments": [...],
# "U_IDC_VALREF": 31181.5,
# ...
# }
"""
serie = factura.get("serie", "")
numero = factura.get("numero", "")
tipo = factura.get("tipo", "01")
total = factura.get("total_factura", 0) or 0
tiene_detraccion = factura.get("tiene_detraccion", False)
codigo_detraccion = factura.get("codigo_detraccion", "")
porcentaje_detraccion = factura.get("porcentaje_detraccion", 0) or 0
monto_detraccion = factura.get("monto_detraccion", 0) or 0
fecha_emision = factura.get("fecha_emision", "")
ruc_emisor = factura.get("ruc_emisor", "")
razon_social_emisor = factura.get("razon_social_emisor", "")
card_code = f"PL{ruc_emisor}"
fecha_contabilizacion = datetime.now().strftime("%Y-%m-%d")
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"
condicion_pago = sapb1_manager.obtener_condicion_pago(card_code)
fecha_vencimiento = calcular_fecha_vencimiento(fecha_contabilizacion, condicion_pago)
if tiene_detraccion:
# neto = round(total - monto_detraccion, 2)
monto_detraccion = math.ceil(monto_detraccion)
neto = total - monto_detraccion
installments = [
{"DueDate": fecha_vencimiento, "Total": monto_detraccion, "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
{"DueDate": fecha_vencimiento, "Total": round(neto,2), "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
]
else:
installments = [
{"DueDate": fecha_vencimiento, "Total": round(total, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
]
doc_lines = []
guia_referencia = ""
for i, linea in enumerate(factura.get("lineas", [])):
resultado_validacion = resultados_val[i] if i < len(resultados_val) else None
guias_lt = (resultado_validacion or {}).get("guias_lt", [])
if not guias_lt:
continue
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)))
doc_lines.append({
"ItemCode": item_code,
"Quantity": qty,
"PriceAfterVAT": lt_val,
"WarehouseCode": item.get("warehouse_code", "A-CENTRA"),
"CostingCode": "PRO",
"CostingCode2": "PRO03",
"TaxCode": "I18",
"FreeText": linea.get("descripcion", "")[:100],
"MeasureUnit": "UND",
"U_BPP_CTAD": "9231101",
"U_BPP_OPER": "A",
"U_tipoOpT12": "02",
"U_VS_ONEROSO": "1",
"U_VS_TIPAFE": "10",
"U_IDC_GRFLETE": codigo_guia,
})
valor_referencial = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
return {
"DocType": "dDocument_Items",
"DocDate": fecha_contabilizacion,
"DocDueDate": fecha_vencimiento,
"TaxDate": fecha_emision,
"CardCode": card_code,
"NumAtCard": f"{tipo}{serie}-{numero}",
"U_IDC_NUMGR": "" if not guia_referencia else guia_referencia,
"DocCurrency": "SOL",
"Comments": f"Factura {serie}-{numero} / {razon_social_emisor}"[:254],
"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",
"U_BPP_MDCD": numero,
"U_BPP_MDSD": serie,
"U_VS_CODSER": f"{codigo_detraccion}02" if codigo_detraccion else "",
"U_VS_AFEDET": "Y" if tiene_detraccion else "N",
"U_VS_PORDET": porcentaje_detraccion,
"U_VS_APLANT": "Y",
"U_BPP_MDSD2": serie,
"U_IDC_FLETE": "02",
"U_IDC_LINEANE": "01",
"U_IDC_VALREF": valor_referencial,
"U_VS_ANEXO_PDF": factura.get("ruta_red_pdf", ""),
"U_VS_ANEXO_XML": factura.get("ruta_red_xml", ""),
"DocumentLines": doc_lines,
"DocumentInstallments": installments,
}
# ============================================================================
def validate_prices(factura: dict) -> tuple[bool, list[dict]]:
"""
Valida los precios de cada guía contra DeliveryNotes en SAP y
prepara los datos necesarios para construir el draft.
Flujo:
1. Obtiene el maestro de zonas SAP (``U_IDC_ZONAS``) para resolver
el nombre de zona a partir de ``U_IDC_DESTINO``.
2. Por cada línea de factura:
a. Itera sobre sus ``guias_remision[]``.
b. Para cada guía consulta SAP via
``sapb1_manager.get_delivery_notes``.
c. Dentro de cada DeliveryNote, suma los ``LineTotal``,
``PriceAfterVAT`` y obtiene la máxima ``Quantity`` de las
líneas cuyo ``ItemCode`` coincide con ``FL\\d``.
d. Acumula los resultados en ``guias_lt[]`` (uno por guía) con
``line_total``, ``quantity``, ``price_after_vat_sum``,
``warehouse_code`` y ``zona``.
3. Determina si los precios coinciden (``ok``):
``importes_coinciden(total_linea, sap_total)``: la diferencia, redondeada a
2 decimales, no supera 1 céntimo.
4. Fija ``draft_quantity`` y ``draft_price`` de cada guía:
si ``ok``, usa SAP (``int(quantity)`` y ``price_after_vat_sum``);
si no, usa el XML (``int(cantidad)`` y ``total_linea / cantidad``).
Args:
factura: Diccionario con los datos de la factura (debe contener
``lineas[]``, y cada línea debe tener ``guias_remision[]``
y ``total_linea``).
Returns:
tuple[bool, list[dict]]:
- ``todas_coinciden``: True si TODAS las líneas tienen
``ok=True``.
- ``resultados``: Lista de resultados por línea, cada uno
con claves: ``grflete``, ``ok``, ``draft_price``,
``sap_price``, ``label``, ``warehouse_code``, ``guias_lt[]``
(y ``draft_cantidad`` en las líneas sin validar).
Example:
Factura con guía ``T001-127773`` y SAP devuelve
``FL10007 qty=350 pav=1.3396, FL20007 qty=350 pav=0``::
ok, resultados = validate_prices(factura)
# → ok = False (397.34 != 633.47)
# → resultados[0]["guias_lt"][0] = {
# "guia": {"tipo":"09","serie":"T001","numero":"127773"},
# "line_total": 397.34,
# "quantity": 350.0,
# "price_after_vat_sum": 1.3396,
# "zona": "CAÑETE",
# "warehouse_code": "A-CENTRA"
# }
"""
resultados = []
factura_lines = factura.get("lineas", [])
# listado de zonas para obtener su descripción
zonas_raw = sapb1_manager.get_zonas()
zonas = {z["Code"]: z["Name"] for z in zonas_raw}
for fl in factura_lines:
# por cada linea de factura se obtiene
# cantidad del xml de la factura
cantidad = float(fl.get("cantidad", 0))
# total de linea antes de impuesto del xml de la factura
total_linea = float(fl.get("total_linea", 0) or 0)
# lista de guias asociadas a la linea de factura de xml o pdf
guias = fl.get("guias_remision", [])
# dificil que la cantidad sea 0, pero esos casos los descartamos
if cantidad == 0:
resultados.append({"grflete": "-", "ok": False, "draft_price": None, "sap_price": None, "label": "Cantidad cero, sin validar", "warehouse_code": "-", "draft_cantidad": cantidad, "guias_lt": []})
continue
# si no hay guia asociada, no se puede validar, lo descartamos
if not guias:
resultados.append({"grflete": "-", "ok": False, "draft_price": None, "sap_price": None, "label": "Sin guía de referencia", "warehouse_code": "-", "draft_cantidad": cantidad, "guias_lt": []})
continue
guias_lt = []
all_wh = set()
all_ok = True
# una linea de la factura puede tener varias guias de remision
# hay que validar cada una de ellas
for guia in guias:
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
try:
delivery_notes = sapb1_manager.get_delivery_notes(
tipo=guia["tipo"], serie=guia["serie"], numero=guia["numero"]
)
wh_set = set()
suma_line_total = 0.0
max_qty = 0.0
sum_pav = 0.0
weight1 = 0.0
zona = ""
for dn in delivery_notes:
# obtenemos la zona de destino del documento
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
# obtenemos el almacen de la primera linea
first_line = dn.get("DocumentLines", [None])[0]
if first_line:
wh = first_line.get("WarehouseCode", "")
if wh:
wh_set.add(wh)
# obtenemos el peso de la primera linea de la guia de remision
# que representa el peso de la guia de remisión del articulo inventariable
# y lo vamos acumulando para obtener el peso total de la linea de la factura
weight1 += float(first_line.get("Weight1", 0) or 0)
# recorremos cada linea del documento
for linea_detalle_dict in dn.get("DocumentLines", []):
# solo procesamos lineas que empiecen con FL\d
if not re.match(r"FL\d", linea_detalle_dict.get("ItemCode", "")):
continue
# obtenemos el total de linea
line_total = float(linea_detalle_dict.get("LineTotal", 0) or 0)
# obtenemos el precio después de impuesto
price_after_vat = float(linea_detalle_dict.get("PriceAfterVAT", 0) or 0)
# obtenemos la cantidad
quantity = float(linea_detalle_dict.get("Quantity", 0) or 0)
# sumamos el total de linea
suma_line_total += line_total
# obtenemos la cantidad maxima
max_qty = max(max_qty, quantity)
sum_pav += price_after_vat
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 sapb1_manager.SapNoDisponible:
raise # una caída de SAP no es un problema de la guía: lo decide quien llama
except Exception as ex:
log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2, severity=logging.WARNING)
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, "error": str(ex)})
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 importes_coinciden(total_linea, sap_total)
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"
for item in guias_lt:
if ok:
item["draft_quantity"] = int(item["quantity"])
item["draft_price"] = item["price_after_vat_sum"]
else:
item["draft_quantity"] = int(cantidad)
item["draft_price"] = float(total_linea) / max(float(cantidad), 1)
resultados.append({
"grflete": grflete_str,
"ok": ok,
"draft_price": total_linea,
"sap_price": sap_total,
"label": label,
"warehouse_code": wh_str,
"guias_lt": guias_lt,
})
if sin_sap:
log("⏭️ Sin guías en SAP — línea saltada", 3)
else:
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, severity=logging.WARNING)
todas_coinciden = all(r["ok"] for r in resultados)
return todas_coinciden, resultados
# ============================================================================
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
+6 -44
View File
@@ -1,29 +1,8 @@
import logging
import os
import zipfile
import database_utilities
# ============================================================================
def quitar_correos_ya_registrados_en_base_de_datos(emails: list[dict]) -> list[dict]:
"""
Verifica cuáles correos son nuevos (no están en la BD), los guarda
y retorna la lista filtrada de los que tienen adjuntos.
"""
if not emails:
return []
# 1. Verificar cuáles ya existen en la BD
ids = [e['id'] for e in emails]
existentes = database_utilities.get_existing_ids(ids)
# Solo nos quedamos con los que NO están en la BD
correos_no_registrados = [e for e in emails if e['id'] not in existentes]
print(f"Correos nuevos detectados: {len(correos_no_registrados)}")
if correos_no_registrados:
print("Guardando nuevos correos en la base de datos...")
database_utilities.save_emails(correos_no_registrados)
return correos_no_registrados
logger = logging.getLogger(__name__)
# ============================================================================
def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
@@ -40,7 +19,7 @@ def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
continue
ruta_zip = os.path.join(ruta, adjunto)
if not os.path.exists(ruta_zip):
print(f"⚠️ No existe: {ruta_zip}")
logger.warning(f"⚠️ No existe: {ruta_zip}")
continue
try:
with zipfile.ZipFile(ruta_zip, "r") as zip_ref:
@@ -51,16 +30,16 @@ def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
]
zip_ref.extractall(ruta)
nuevos_adjuntos.extend(nombres_extraidos)
print(
logger.info(
f"📦 {adjunto}: {len(nombres_extraidos)} archivos extraídos"
)
except Exception as e:
print(f"⚠️ Error descomprimiendo {adjunto}: {e}")
logger.warning(f"⚠️ Error descomprimiendo {adjunto}: {e}")
email["adjuntos"].extend(nuevos_adjuntos)
return emails
except Exception as ex:
print(f"❌ Error general: {ex}")
logger.error(f"❌ Error general: {ex}")
return emails
# ============================================================================
@@ -73,21 +52,4 @@ def filtrar_archivos_validos(archivos: list[str]) -> list[str]:
return [a for a in archivos if a.lower().endswith(extensiones_validas)]
# ============================================================================
def procesar_adjuntos_email(e: dict, archivos: list[str], ruta_descarga: str) -> list[str]:
"""
Descomprime y filtra los archivos de un correo.
Retorna la lista de archivos válidos si existen, de lo contrario lista vacía.
"""
# 1. Descomprimir archivos ZIP si existen
archivos_procesados = descomprimir_adjuntos(archivos, ruta_descarga)
# 2. Filtrar solo PDF y XML
archivos_validos = filtrar_archivos_validos(archivos_procesados)
if archivos_validos:
e['archivos'] = archivos_validos
return archivos_validos
return []
# ============================================================================
+45 -25
View File
@@ -1,17 +1,19 @@
#!/usr/bin/env python3
import logging
import os
import sys
import json
from datetime import datetime, date
from hdbcli import dbapi
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
# Cargar variables de entorno desde .env (ruta absoluta: no depende del
# directorio de trabajo desde el que se lance el proceso)
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
# Configuración de la API
HANA_HOST = os.getenv("HANA_SERVER", "")
@@ -32,52 +34,69 @@ def get_connection():
)
return conn
except Exception as e:
print(f" ❌ Error conectando a HANA: {e}")
logger.error(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.
Si no encuentra el tipo, serie y número en ODLN, busca en OWTR
(Traslados de Inventario) como fallback.
Args:
tipo: Código de serie/documento (U_BPP_MDSD).
serie: Serie del documento (U_BPP_MDTD).
tipo: Código de tipo de documento SUNAT, ej. "09"/"31" (U_BPP_MDTD).
serie: Serie del documento, ej. "T001" (U_BPP_MDSD).
numero: Número del documento (se busca con LIKE).
Returns:
list[dict] — Lista con DocEntry, DocNum, CardCode, CardName.
list[dict] — Lista con DocEntry, DocNum, CardCode, CardName, Tabla.
"Tabla" indica en qué tabla se encontró el registro ("ODLN" u
"OWTR"), para que el llamador sepa qué endpoint de Service Layer
usar (DeliveryNotes o StockTransfers).
"""
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:
results = _query_delivery_table(connection, "ODLN", tipo, serie, numero)
if results:
return results
return _query_delivery_table(connection, "OWTR", tipo, serie, numero)
finally:
connection.close()
# ============================================================================
def _query_delivery_table(connection, tabla: str, tipo: str, serie: str, numero: str) -> list:
"""Ejecuta la consulta de tipo/serie/número contra la tabla indicada (ODLN u OWTR)."""
schema = HANA_SCHEMA
query = f'''SELECT "DocEntry", "DocNum", "CardCode", "CardName"
FROM "{schema}"."{tabla}"
WHERE "CANCELED" = 'N'
AND "U_BPP_MDTD" = ?
AND "U_BPP_MDSD" = ?
AND "U_BPP_MDCD" LIKE ?'''
pattern = f"%{numero}%"
cursor = None
try:
cursor = connection.cursor()
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)))
registro = dict(zip(columns, row))
registro["Tabla"] = tabla
results.append(registro)
return results
except Exception as e:
print(f" ❌ Error en consulta HANA get_delivery: {e}")
logger.error(f" ❌ Error en consulta HANA get_delivery ({tabla}): {e}")
return []
finally:
if cursor:
cursor.close()
if connection:
connection.close()
# ============================================================================
def obtener_valor_referencial(serie: str, numero: str) -> float:
@@ -100,8 +119,9 @@ def obtener_valor_referencial(serie: str, numero: str) -> float:
if not connection:
return 0.0
cursor = connection.cursor()
cursor = None
try:
cursor = connection.cursor()
cursor.execute(query, (serie, numero))
columns = [column[0] for column in cursor.description]
row = cursor.fetchone()
@@ -110,7 +130,7 @@ def obtener_valor_referencial(serie: str, numero: str) -> float:
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}")
logger.error(f" ❌ Error en consulta HANA obtener_valor_referencial: {e}")
return 0.0
finally:
if cursor:
+150
View File
@@ -0,0 +1,150 @@
import logging
import contextvars
import os
import sys
from contextlib import contextmanager
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
LOG_DIR = Path(__file__).parent / "logs"
LOG_FILE = LOG_DIR / "descarga_factura_transporte.log"
_LOG_CONFIGURED = False
_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[])
# ============================================================================
class TreeFormatter(logging.Formatter):
def format(self, record):
return f"{self.formatTime(record)} ─── {record.getMessage()}"
def formatTime(self, record, datefmt=None):
import datetime
return datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
# ============================================================================
def _build_prefix(stack, is_last):
if not stack:
return ""
parts = []
for sib_is_last in stack[:-1]:
parts.append(" " if sib_is_last else "│ ")
parts.append("└── " if is_last else "├── ")
return "".join(parts)
# ============================================================================
class _TaskContext:
def __init__(self, logger, msg, emoji, is_last):
self._logger = logger
self._msg = msg
self._emoji = emoji
self._is_last = is_last
def __enter__(self):
stack = _stack.get()
prefix = _build_prefix(stack, self._is_last)
emoji_str = f"{self._emoji} " if self._emoji else ""
self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}")
_stack.set(stack + [self._is_last])
return TaskLogger(self._logger._logger)
def __exit__(self, exc_type, exc_val, exc_tb):
_stack.set(_stack.get()[:-1])
# ============================================================================
class TaskLogger:
def __init__(self, logger):
self._logger = logger
@contextmanager
def task(self, msg, emoji=""):
with _TaskContext(self, msg, emoji, is_last=False) as sub:
yield sub
@contextmanager
def last_task(self, msg, emoji=""):
with _TaskContext(self, msg, emoji, is_last=True) as sub:
yield sub
def info(self, msg, *args, _last=False, **kwargs):
self._log(logging.INFO, msg, args, kwargs, _last)
def warning(self, msg, *args, _last=False, **kwargs):
self._log(logging.WARNING, msg, args, kwargs, _last)
def error(self, msg, *args, _last=False, **kwargs):
self._log(logging.ERROR, msg, args, kwargs, _last)
def _log(self, level, msg, args, kwargs, is_last):
stack = _stack.get()
prefix = _build_prefix(stack, is_last)
self._logger.log(level, f"{prefix}{msg}", *args, **kwargs)
class _StreamToLogger:
"""Redirige escrituras de un stream (stdout/stderr) al logger, línea por línea."""
def __init__(self, logger: logging.Logger, level: int) -> None:
self._logger = logger
self._level = level
self._buffer = ""
def write(self, message: str) -> None:
self._buffer += message
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
if line:
self._logger.log(self._level, line)
def flush(self) -> None:
pass
# ============================================================================
def setup_logging(level=None, console=True, capture_print=True):
"""
Configura el logging del proceso: archivo rotado a medianoche (30 días
de historial) y, opcionalmente, consola. Si ``capture_print`` es True
(por defecto), también redirige ``print()`` (stdout/stderr) al log, de
forma que cualquier salida existente en el proyecto quede registrada
en archivo sin tener que migrar cada ``print`` a ``logger.info``.
"""
global _LOG_CONFIGURED
if _LOG_CONFIGURED:
return
if level is None:
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
LOG_DIR.mkdir(exist_ok=True)
formatter = TreeFormatter()
file_handler = TimedRotatingFileHandler(
LOG_FILE,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
handlers: list[logging.Handler] = [file_handler]
if console:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
handlers.append(console_handler)
logging.basicConfig(level=level, handlers=handlers, force=True)
if capture_print:
stdout_logger = logging.getLogger("stdout")
stderr_logger = logging.getLogger("stderr")
sys.stdout = _StreamToLogger(stdout_logger, logging.INFO)
sys.stderr = _StreamToLogger(stderr_logger, logging.ERROR)
_LOG_CONFIGURED = True
# ============================================================================
def get_logger(name="descarga_factura_transporte"):
setup_logging()
return TaskLogger(logging.getLogger(name))
-72
View File
@@ -1,72 +0,0 @@
import sys
import os
from datetime import datetime, timedelta
from typing import TextIO
# ============================================================================
class MultiStream:
"""
Clase para redirigir la salida a múltiples flujos (consola y archivo).
"""
def __init__(self, *streams: TextIO) -> None:
self.streams: tuple[TextIO, ...] = streams
def write(self, data: str) -> None:
for stream in self.streams:
stream.write(data)
stream.flush()
def flush(self) -> None:
for stream in self.streams:
stream.flush()
# ============================================================================
def setup_logger() -> str:
"""
Configura la redirección de stdout y stderr a un archivo de log y a la consola.
Retorna la ruta del archivo de log creado.
"""
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_filename = f"{timestamp}.log"
log_path = os.path.join(log_dir, log_filename)
# Abrir archivo en modo append con encoding utf-8 para soportar iconos
log_file = open(log_path, "a", encoding="utf-8")
# Redirigir stdout y stderr
sys.stdout = MultiStream(sys.stdout, log_file)
sys.stderr = MultiStream(sys.stderr, log_file)
return log_path
# ============================================================================
def limpiar_logs_antiguos(dias: int = 7) -> None:
"""
Elimina archivos de log con más de 'n' días de antigüedad.
"""
log_dir = "logs"
if not os.path.exists(log_dir):
return
limite = datetime.now() - timedelta(days=dias)
count = 0
for archivo in os.listdir(log_dir):
ruta_archivo = os.path.join(log_dir, archivo)
if os.path.isfile(ruta_archivo) and archivo.endswith(".log"):
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo))
if fecha_archivo < limite:
try:
os.remove(ruta_archivo)
count += 1
except Exception as e:
print(f"⚠️ Error al eliminar log {archivo}: {e}")
if count > 0:
print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
# ============================================================================
+30 -23
View File
@@ -1,20 +1,29 @@
import logging
import os
import time
import argparse
from datetime import datetime, timedelta
from dotenv import load_dotenv
import logger_utilities
import logger_manager
from config_validator import verificar_configuracion
from pipeline import run_pipeline
from pipeline_utils import version_del_proyecto
logger = logging.getLogger(__name__)
# ============================================================================
# (hora inicio, min inicio, hora fin, min fin, intervalo en segundos, modo). El fin no se incluye.
# Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba tests/test_horarios.py).
HORARIOS = [
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 60 segundos
(5, 0, 6, 30, 1800, "full"), # 05:00 - 06:30 -> cada 30 minutos
(6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos
(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
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 18:30 -> cada 5 minutos
(18, 30, 21, 30, 3600, "full"), # 18:30 - 21:30 -> cada 60 minutos
(21, 30, 24, 0, 3600, "full"), # 21:30 - 24:00 -> cada 60 minutos
]
# ============================================================================
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
@@ -65,14 +74,16 @@ def main() -> None:
dias = args.dias
load_dotenv(dotenv_path=".env")
# Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
verificar_configuracion()
EMAIL = os.getenv("EMAIL", "")
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER", "")
OUTPUT_FOLDER = os.getenv("OUTPUT_FOLDER", "")
print("🚀 Demonio iniciado")
print("=" * 60)
logger.info("🚀 Demonio iniciado")
logger.info("=" * 60)
try:
while True:
@@ -85,8 +96,8 @@ def main() -> None:
intervalo, modo = resultado
print(
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
logger.info(
f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
)
@@ -95,14 +106,14 @@ def main() -> None:
duracion = (datetime.now() - inicio_ciclo).total_seconds()
if duracion > intervalo:
print(
logger.warning(
f"⚠️ El ciclo demoró {duracion:.1f}s "
f"(intervalo configurado {intervalo}s)"
)
dormir = max(0, intervalo - duracion)
print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
logger.info(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
else:
@@ -110,26 +121,22 @@ def main() -> None:
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}"
)
logger.info(f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Fuera del horario.")
logger.info(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.")
logger.info("🛑 Demonio detenido por el usuario.")
# ============================================================================
if __name__ == "__main__":
log_path = logger_utilities.setup_logger()
logger_utilities.limpiar_logs_antiguos(dias=30)
logger_manager.setup_logging()
print("=" * 60)
print(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S}")
print(f"📄 Log guardado en: {log_path}")
print("=" * 60)
logger.info("=" * 60)
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S} (versión {version_del_proyecto()})")
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
logger.info("=" * 60)
main()
+123
View File
@@ -0,0 +1,123 @@
"""Cuerpos HTML de los correos de notificación."""
from pipeline_utils import importes_coinciden
# ============================================================================
def build_validation_html(resultados: list[dict]) -> str:
"""
Genera una tabla HTML con el resultado de la validación de precios
para incluir en el cuerpo del email de notificación.
Args:
resultados: Lista de resultados de ``validate_prices``.
Returns:
str — Código HTML de la tabla con columnas:
Guía, Estado, Draft Price, SAP, Almacén, Resultado.
Example:
Con un resultado ``ok=True`` y ``grflete="09T001-127773"``::
html = build_validation_html(resultados)
# → "<table ...><tr><td>09T001-127773</td><td>✅</td>...</tr></table>"
"""
rows = ""
for r in resultados:
icono = "✅" if r["ok"] else "❌"
dp = f"{r['draft_price']:.6f}" if r["draft_price"] is not None else "-"
sap_price_str = f"{r['sap_price']:.6f}" if r["sap_price"] is not None else "-"
rows += f"<tr><td>{r['grflete']}</td><td>{icono}</td><td>{dp}</td><td>{sap_price_str}</td><td>{r['warehouse_code']}</td><td>{r['label']}</td></tr>"
return (
"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse;'>"
"<tr><th>Guía</th><th>Estado</th><th>Draft Price</th><th>SAP</th><th>Almacén</th><th>Resultado</th></tr>"
f"{rows}</table>"
)
# ============================================================================
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 = importes_coinciden(valor_xml, total_sap)
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.
Args:
status: ``"created"`` (draft creado), ``"duplicate"`` (la factura ya
existe en SAP), ``"error"`` (error al
crear), o cualquier otro valor (validación falló, draft no
creado).
factura: Diccionario de la factura (se usa ``serie``,
``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ó.
Returns:
str — Cuerpo HTML completo del email.
Example:
Draft creado exitosamente::
body = build_notification_body(
"created", factura, tabla_html, valref_html, doc_entry=2021
)
# → "<p>✅ Draft SAP B1 creado exitosamente.</p>..."
"""
base = (
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}{valref_section}"
elif status == "duplicate":
existente = kwargs.get("existente") or {}
return f"<p>⏭️ Draft NO creado: la factura ya existe en SAP ({existente.get('entidad')} {existente.get('DocEntry')}).</p>{base}"
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}{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}{valref_section}"
+165
View File
@@ -0,0 +1,165 @@
"""
Catálogo de observaciones: los motivos por los que se rechaza un comprobante.
Cada observación se muestra al proveedor en la plantilla de rechazo con un
título, unas filas de detalle y un texto de "cómo corregir". **Los textos de
este archivo son un borrador** para revisar y ajustar; no hace falta tocar la
lógica para cambiarlos.
``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en
observaciones; el resto son los motivos de un correo sin comprobante válido.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Observacion:
codigo: str # identificador estable (se guarda en la BD)
titulo: str
filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle
como_corregir: str = ""
def a_dict(self) -> dict:
"""Forma serializable: se guarda en ``comprobantes.observaciones``."""
return {
"codigo": self.codigo,
"titulo": self.titulo,
"filas": [{"etiqueta": e, "valor": v} for e, v in self.filas],
"como_corregir": self.como_corregir,
}
# ============================================================================
# Formato
# ============================================================================
def moneda(valor: float | int | None) -> str:
"""``1250`` → ``'S/ 1,250.00'``."""
return f"S/ {float(valor or 0):,.2f}"
def texto_observaciones(cantidad: int) -> str:
"""``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``."""
return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones"
def _nombre_guia(guia: dict) -> str:
return f"{guia.get('serie', '')}-{guia.get('numero', '')}"
# ============================================================================
# Motivos de rechazo de una factura (validación contra SAP)
# ============================================================================
def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float,
guias: list[str], varias_lineas: bool) -> Observacion:
filas = []
if varias_lineas:
filas.append(("Línea", str(numero_linea)))
filas += [
("Guías", ", ".join(guias)),
("Importe en su factura", moneda(importe_factura)),
("Importe esperado", moneda(importe_esperado)),
]
return Observacion(
"precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas),
"Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.",
)
def guia_no_encontrada(guia: str) -> Observacion:
return Observacion(
"guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),),
"Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.",
)
def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
filas = [("Línea", str(numero_linea))]
if descripcion:
filas.append(("Descripción", descripcion[:60]))
return Observacion(
"linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas),
"Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.",
)
def factura_ya_registrada(numero_comprobante: str) -> Observacion:
return Observacion(
"factura_ya_registrada", "Esta factura ya está registrada en nuestro sistema",
(("Comprobante", numero_comprobante),),
"No necesita enviarla otra vez. Si tiene dudas sobre su estado, escríbanos indicando el código de seguimiento.",
)
def cantidad_cero(numero_linea: int) -> Observacion:
return Observacion(
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
"Corrija la cantidad de la línea y emita el comprobante nuevamente.",
)
# ============================================================================
# Motivos de rechazo de un correo sin comprobante válido
# ============================================================================
def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
return Observacion(
"sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
(("Archivos recibidos", str(archivos_recibidos)),),
"Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
)
def falta_xml(pdfs_recibidos: int) -> Observacion:
return Observacion(
"falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
"Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
)
def sin_factura(documentos_recibidos: list[str]) -> Observacion:
return Observacion(
"sin_factura", "No encontramos una factura electrónica en su correo",
(("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
"Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
)
# ============================================================================
# Resultado de la validación de precios → observaciones
# ============================================================================
def _sin_datos_sap(item: dict) -> bool:
"""La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
"""
Observaciones de una factura a partir de los resultados de ``validate_prices``
(uno por línea). Una línea que no coincide da una observación por cada guía
que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
"""
lineas = factura.get("lineas", [])
varias_lineas = len(lineas) > 1
observaciones: list[Observacion] = []
for i, resultado in enumerate(resultados_val):
if resultado.get("ok"):
continue
numero = i + 1
linea = lineas[i] if i < len(lineas) else {}
etiqueta = resultado.get("label", "")
if etiqueta == "Cantidad cero, sin validar":
observaciones.append(cantidad_cero(numero))
elif etiqueta == "Sin guía de referencia":
observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
else:
guias_lt = resultado.get("guias_lt", [])
no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
if len(no_encontradas) < len(guias_lt):
observaciones.append(precio_no_coincide(
numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
[_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
))
return observaciones
+52 -12
View File
@@ -1,3 +1,4 @@
import logging
import msal
import requests
import warnings
@@ -5,27 +6,30 @@ import base64
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
warnings.filterwarnings("ignore")
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
# Cargar variables de entorno desde .env (ruta absoluta: no depende del
# directorio de trabajo desde el que se lance el proceso)
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
# Configuración de la API
CLIENT_ID = os.getenv("CLIENT_ID", "")
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "")
TENANT_ID = os.getenv("TENANT_ID", "")
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER","")
VERIFY_SSL = os.getenv("VERIFI_SSL", "False").lower() == "true"
VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true"
# ============================================================================
def get_access_token() -> str:
"""
Obtiene un token de acceso de Microsoft Graph usando las credenciales
configuradas en config.json (Client ID, Client Secret y Tenant ID).
del .env (CLIENT_ID, CLIENT_SECRET y TENANT_ID).
"""
authority = f"https://login.microsoftonline.com/{TENANT_ID}"
@@ -48,11 +52,30 @@ def get_access_token() -> str:
correlation_id = result.get("correlation_id")
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
# ============================================================================
def es_mensaje_automatico(headers: list[dict] | None) -> bool:
"""
Indica si un mensaje fue generado automáticamente (respuesta automática,
rebote, lista de distribución…) a partir de ``internetMessageHeaders``.
No se debe responder a estos mensajes: se generarían bucles de correos.
"""
h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []}
if h.get("auto-submitted", "no") not in ("", "no"):
return True
if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h:
return True
if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"):
return True
return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega
# ============================================================================
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
"""
Obtiene los correos de una cuenta específica en un rango de fechas.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach
y es_automatico (True si es una respuesta automática o un rebote).
"""
headers = {
"Authorization": f"Bearer {token}",
@@ -69,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
params = {
"$filter": filter_query,
"$select": "id,receivedDateTime,hasAttachments,subject,from",
"$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
"$top": 50
}
@@ -103,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
"sender": sender,
"subject": msg.get("subject", ""),
"has_attach": msg.get("hasAttachments")
"has_attach": msg.get("hasAttachments"),
"es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")),
})
url = data.get("@odata.nextLink")
@@ -149,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
return downloaded_files
# ============================================================================
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None) -> bool:
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool:
"""
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
@@ -159,6 +183,8 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
:param subject: Asunto del correo.
:param html_body: Contenido del correo en formato HTML.
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
:param cc_recipients: Lista de correos electrónicos en copia (CC).
:param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios.
:return: True si se envió correctamente, False en caso contrario.
"""
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
@@ -178,6 +204,18 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
}
})
# Construir lista de destinatarios en copia (CC) para Graph API
cc_payload = []
for email in (cc_recipients or []):
cc_payload.append({
"emailAddress": {
"address": email
}
})
# Construir lista de destinatarios en copia oculta (BCC) para Graph API
bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])]
# Construir lista de adjuntos
attachments_payload = []
if attachment_paths:
@@ -203,7 +241,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
"contentBytes": encoded_content
})
else:
print(f"Advertencia: El archivo no existe y no se adjuntará: {path}")
logger.warning(f"Advertencia: El archivo no existe y no se adjuntará: {path}")
# Estructura del mensaje para la API
email_data = {
@@ -214,6 +252,8 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
"content": html_body
},
"toRecipients": recipients_payload,
"ccRecipients": cc_payload,
"bccRecipients": bcc_payload,
"attachments": attachments_payload
},
"saveToSentItems": "true"
@@ -222,13 +262,13 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
try:
response = requests.post(url, headers=headers, json=email_data, verify=verify_ssl)
if response.status_code == 202:
print(f" ├── SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}")
logger.info(f" ├── SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}")
return True
else:
print(f" ├── ERROR: Al enviar correo: {response.status_code} - {response.text}")
logger.error(f" ├── ERROR: Al enviar correo: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f" ├── ERROR: Excepcion al enviar correo: {e}")
logger.error(f" ├── ERROR: Excepcion al enviar correo: {e}")
return False
# ============================================================================
+11 -4
View File
@@ -1,6 +1,12 @@
import pdfplumber
import os
import re
import unicodedata
def _sin_tildes(texto: str) -> str:
"""Quita tildes/diacríticos para comparaciones tolerantes a variaciones de formato."""
return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c))
TIPO_MAP = {
@@ -53,13 +59,14 @@ def _extract_base_data(text: str, pdf_path: str) -> dict:
if standalone_ruc:
data["ruc_emisor"] = standalone_ruc.group(1)
if "FACTURA ELECTRÓNICA" in text.upper():
text_norm = _sin_tildes(text.upper())
if "FACTURA ELECTRONICA" in text_norm:
data["tipo_documento"] = "FACTURA"
elif "BOLETA DE VENTA" in text.upper():
elif "BOLETA DE VENTA" in text_norm:
data["tipo_documento"] = "BOLETA"
elif "TRANSPORTISTA" in text.upper() and "GUÍA DE REMISIÓN" in text.upper():
elif "TRANSPORTISTA" in text_norm and "GUIA DE REMISION" in text_norm:
data["tipo_documento"] = "GUIA DE REMISION TRANSPORTISTA"
elif "GUÍA DE REMISIÓN" in text.upper() or "GRRE:" in text.upper():
elif "GUIA DE REMISION" in text_norm or "GRRE:" in text_norm:
data["tipo_documento"] = "GUIA DE REMISION"
serie_numero_match = re.search(r"(?:N°\s*)?([A-Z0-9]{4})\s*-\s*(\d+)", text)
+165
View File
@@ -0,0 +1,165 @@
"""Persistencia a disco (JSON) de resultados y drafts."""
import os
import re
import json
from datetime import datetime
import sunat_schema
import pdf_utilities
from pipeline_utils import log, ruta_carpeta
# ============================================================================
def guardar_resultado(documentos: list[dict], datos_pdf: list[dict], output_folder: str) -> list[str]:
"""
Guarda los resultados XML y PDF en archivos JSON separados.
El nombre del archivo sigue el patrón
``{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, pdf_datos, output_folder)
# → 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 []
output_folder = ruta_carpeta(output_folder) # relativa -> junto al código; absoluta -> tal cual
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")
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)
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": None, # el PDF no extrae la razón social del emisor
"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 guardar_draft_json(draft: dict, serie: str, numero: str, output_folder: str) -> None:
"""
Guarda el payload del draft SAP B1 en un archivo JSON para
depuración.
El nombre sigue el patrón
``draft_sapb1_{serie}-{numero}_{timestamp}.json``.
Args:
draft: Payload del draft SAP B1 (producido por
``build_sap_draft``).
serie: Serie de la factura (ej. ``"F002"``).
numero: Número de la factura (ej. ``"486"``).
Example:
Draft de factura F002-486::
guardar_draft_json(draft, "F002", "486", output_folder)
# → Crea draft_sapb1_F002-486_2026-07-08_12-00-00.json
"""
output_folder = ruta_carpeta(output_folder)
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
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)
log(f"💾 Draft guardado: {filename}")
+284 -1063
View File
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
"""Utilidades compartidas por los módulos del pipeline."""
import os
import logging
import tomllib
logger = logging.getLogger("pipeline")
def ruta_carpeta(subcarpeta: str) -> str:
"""Retorna la ruta absoluta de ``subcarpeta`` relativa a la raíz del proyecto."""
return os.path.join(os.path.dirname(__file__), subcarpeta)
def log(msg: str, level: int = 1, last: bool = False, severity: int = logging.INFO) -> None:
"""
Registra ``msg`` con la indentación de árbol usada en el log del pipeline.
Args:
msg: Mensaje a registrar.
level: Nivel de indentación del árbol (1-4).
last: True si es el último hijo de su rama (``└──`` en vez de ``├──``).
severity: Nivel de ``logging`` (``logging.WARNING``, ``logging.ERROR``…).
Por defecto ``logging.INFO``.
"""
branch = " └── " if last else " ├── "
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
logger.log(severity, f"{pfx}{msg}")
def version_del_proyecto() -> str:
"""Versión de ``pyproject.toml`` (por ejemplo ``1.2.0``), o ``desconocida`` si no se puede leer."""
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml"), "rb") as f:
return tomllib.load(f)["project"]["version"]
except (OSError, KeyError, tomllib.TOMLDecodeError):
return "desconocida"
# Diferencia máxima, en soles, entre el importe de la factura y el de SAP para darlos por iguales.
TOLERANCIA_IMPORTE = 0.01
def importes_coinciden(a: float, b: float) -> bool:
"""
True si dos importes son iguales **a nivel de céntimos**, que es lo que muestra un comprobante.
La diferencia se redondea a 2 decimales y se admite hasta ``TOLERANCIA_IMPORTE`` (1 céntimo).
Comparar la diferencia sin redondear (``abs(a - b) < 0.01``) no es fiable: con coma flotante,
431.41 - 431.40 da 0.01000000000004775 (se rechazaba) y 624.39 - 624.38 da 0.00999999999999091
(se aceptaba), de modo que el mismo céntimo pasaba o no según el importe.
"""
return round(abs(a - b), 2) <= TOLERANCIA_IMPORTE
+297
View File
@@ -0,0 +1,297 @@
{
"aceptado_con_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F002-486 · ABR-261019-00042",
"preheader": "Su factura F002-486 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00042",
"fecha_estimada_pago": "18/11/2026",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": true
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
},
{
"guia": "T001-127775",
"importe_factura": "S/ 1,250.00",
"conforme": true
}
]
}
},
"aceptado_sin_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "SERVICIOS LOGÍSTICOS DEL SUR S.A.",
"proveedor_ruc": "20111111111",
"asunto": "Aceptado · Factura E001-2403 · ABR-261019-00043",
"preheader": "Su factura E001-2403 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "E001-2403",
"codigo_seguimiento": "ABR-261019-00043",
"fecha_estimada_pago": null,
"guias": [
{
"guia": "T001-129352",
"importe_factura": "S/ 828.73",
"conforme": true
}
]
}
},
"aceptado_muchas_guias": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F004-5989 · ABR-261019-00044",
"preheader": "Su factura F004-5989 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F004-5989",
"codigo_seguimiento": "ABR-261019-00044",
"fecha_estimada_pago": "30/11/2026",
"guias": [
{
"guia": "T001-130000",
"importe_factura": "S/ 137.50",
"conforme": true
},
{
"guia": "T001-130001",
"importe_factura": "S/ 275.00",
"conforme": true
},
{
"guia": "T001-130002",
"importe_factura": "S/ 412.50",
"conforme": true
},
{
"guia": "T001-130003",
"importe_factura": "S/ 550.00",
"conforme": true
},
{
"guia": "T001-130004",
"importe_factura": "S/ 687.50",
"conforme": true
},
{
"guia": "T001-130005",
"importe_factura": "S/ 825.00",
"conforme": true
},
{
"guia": "T001-130006",
"importe_factura": "S/ 962.50",
"conforme": true
},
{
"guia": "T001-130007",
"importe_factura": "S/ 1,100.00",
"conforme": true
},
{
"guia": "T001-130008",
"importe_factura": "S/ 1,237.50",
"conforme": true
},
{
"guia": "T001-130009",
"importe_factura": "S/ 1,375.00",
"conforme": true
},
{
"guia": "T001-130010",
"importe_factura": "S/ 1,512.50",
"conforme": true
},
{
"guia": "T001-130011",
"importe_factura": "S/ 1,650.00",
"conforme": true
}
]
}
},
"rechazado_dos_observaciones": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Rechazado · Factura F002-486 · ABR-261019-00045",
"preheader": "Tiene 2 observaciones. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00045",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": false
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
}
],
"observaciones_texto": "2 observaciones",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
},
{
"numero": 2,
"titulo": "No encontramos la guía T001-128912",
"filas": [
{
"etiqueta": "Guía",
"valor": "T001-128912"
}
],
"como_corregir": "Verifique que la guía esté registrada y que la serie y el número estén bien escritos."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_una_observacion": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES \"EL RÁPIDO\" <Lima> E.I.R.L.",
"proveedor_ruc": "10060028686",
"asunto": "Rechazado · Factura F002-2450 · ABR-261019-00046",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-2450",
"codigo_seguimiento": "ABR-261019-00046",
"guias": [
{
"guia": "T001-128912",
"importe_factura": "S/ 624.38",
"conforme": false
}
],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_sin_comprobante": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "comprobante",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "proveedor@ejemplo.com",
"proveedor_ruc": null,
"asunto": "Rechazado · Comprobante no recibido · ABR-261019-00047",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": null,
"codigo_seguimiento": "ABR-261019-00047",
"guias": [],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El correo no trae el XML ni el PDF del comprobante",
"filas": [
{
"etiqueta": "Archivos recibidos",
"valor": "0"
}
],
"como_corregir": "Adjunte el XML y el PDF del comprobante en el mismo correo."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": false
}
},
"fuera_de_linea": {
"plantilla": "fuera-de-linea",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "En cola · Factura F002-486 · ABR-261019-00048",
"preheader": "Su factura F002-486 quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00048"
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#E8F3EC" style="background:#E8F3EC;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#1D7A46;"><span aria-hidden="true" style="color:#1D7A46;">&#10003;&nbsp;</span><span style="color:#1D7A46;">ACEPTADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Registramos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Pasó todas las validaciones. <strong>No necesita hacer nada más.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
{{#fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Pago estimado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{fecha_estimada_pago}}</td>
</tr>
{{/fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#1D7A46;">Aceptado</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">El pago sigue según las condiciones acordadas.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FFF4E0" style="background:#FFF4E0;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#9A5B00;"><span aria-hidden="true" style="color:#9A5B00;">&#9679;&nbsp;</span><span style="color:#9A5B00;">EN COLA</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Recibimos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Nuestro sistema está <strong>temporalmente fuera de línea</strong> y no pudimos validarla todavía. Quedó en cola y la procesaremos automáticamente. <strong>No necesita reenviarla.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#9A5B00;">En cola · servicio fuera de línea</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Le enviaremos la respuesta (aceptado o rechazado) a este mismo correo apenas se procese.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FDECE9" style="background:#FDECE9;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;"><span aria-hidden="true" style="color:#C0291C;">&#10005;&nbsp;</span><span style="color:#C0291C;">RECHAZADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">No pudimos registrar su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Tiene <strong>{{observaciones_texto}}</strong>. Corríjalas y envíenos el comprobante nuevamente.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}{{#proveedor_ruc}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span>{{/proveedor_ruc}}</td>
</tr>
{{#comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
{{/comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#C0291C;">Rechazado · {{observaciones_texto}}</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
{{#tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
{{/tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Observaciones</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#observaciones}}
<tr><td style="padding:0 0 18px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;">OBSERVACIÓN {{numero}}</p>
<p style="margin:2px 0 6px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">{{titulo}}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-left:2px solid #C0291C;padding:0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#filas}}
<tr>
<td style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">{{etiqueta}}</td>
<td align="right" style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;white-space:nowrap;">{{valor}}</td>
</tr>
{{/filas}}
</table>
</td></tr></table>
<p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:19px;color:#666666;">{{como_corregir}}</p>
</td></tr>
{{/observaciones}}
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Para continuar</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">1</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Emita el comprobante corregido. Si corresponde, anule el anterior con una nota de crédito.</td>
</tr>
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">2</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Envíe el XML y PDF a <a href="mailto:{{envio_correo}}" style="color:#B84800;font-weight:700;">{{envio_correo}}</a> con el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong>.</td>
</tr>
</table>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
"""
Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``).
Las plantillas usan un subconjunto de Mustache:
{{variable}} valor, con escape de HTML
{{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista,
o se muestra una vez si el valor es verdadero
{{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío
El renderizado es **estricto**: toda variable o sección debe existir en el
contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que
falta provoca un error al renderizar, en vez de un correo con huecos.
No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se
usan en las plantillas.
"""
import html
import os
import re
CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla")
_ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}")
class PlantillaError(ValueError):
"""La plantilla está mal formada o el contexto no tiene algún dato que necesita."""
# ============================================================================
def _analizar(texto: str) -> list:
"""Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``."""
raiz: list = []
pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas
actual = raiz
posicion = 0
for m in _ETIQUETA.finditer(texto):
if m.start() > posicion:
actual.append(("texto", texto[posicion:m.start()]))
posicion = m.end()
marca, nombre = m.group(1), m.group(2)
if marca == "":
actual.append(("var", nombre))
elif marca in ("#", "^"):
hijos: list = []
actual.append(("sec", nombre, hijos, marca == "^"))
pila.append((nombre, actual, marca == "^"))
actual = hijos
else: # cierre
linea = texto.count("\n", 0, m.start()) + 1
if not pila or pila[-1][0] != nombre:
raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta")
actual = pila.pop()[1]
if pila:
raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra")
if posicion < len(texto):
actual.append(("texto", texto[posicion:]))
return raiz
def _buscar(nombre: str, pila: list):
for marco in reversed(pila):
if isinstance(marco, dict) and nombre in marco:
return marco[nombre]
raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla")
def _generar(nodos: list, pila: list) -> str:
salida: list[str] = []
for nodo in nodos:
tipo = nodo[0]
if tipo == "texto":
salida.append(nodo[1])
elif tipo == "var":
valor = _buscar(nodo[1], pila)
salida.append("" if valor is None else html.escape(str(valor), quote=True))
else:
_, nombre, hijos, invertida = nodo
valor = _buscar(nombre, pila)
if invertida:
if not valor:
salida.append(_generar(hijos, pila))
elif isinstance(valor, list):
for elemento in valor:
salida.append(_generar(hijos, pila + [elemento]))
elif valor:
salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila))
return "".join(salida)
# ============================================================================
def renderizar(texto: str, contexto: dict) -> str:
"""Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato."""
return _generar(_analizar(texto), [contexto])
def nombres_usados(texto: str) -> set[str]:
"""Todos los nombres (variables y secciones) que usa la plantilla."""
def recorrer(nodos):
for n in nodos:
if n[0] == "var":
yield n[1]
elif n[0] == "sec":
yield n[1]
yield from recorrer(n[2])
return set(recorrer(_analizar(texto)))
def cargar_plantilla(nombre: str) -> str:
"""Lee ``plantilla/plantilla-<nombre>.html`` (por ejemplo ``aceptado`` o ``rechazado``)."""
ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html")
with open(ruta, encoding="utf-8") as f:
return f.read()
def renderizar_plantilla(nombre: str, contexto: dict) -> str:
"""Carga la plantilla ``nombre`` y la renderiza con ``contexto``."""
return renderizar(cargar_plantilla(nombre), contexto)
+33 -13
View File
@@ -1,27 +1,47 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.backends._legacy:_Backend"
build-backend = "setuptools.build_meta"
[project]
name = "descarga_factura_transporte"
version = "1.1.0"
version = "1.2.1"
description = "Descarga y procesamiento de facturas de transporte desde Office365"
requires-python = ">=3.11"
dependencies = [
"certifi==2026.6.17",
"cffi==2.1.0",
"charset-normalizer==3.4.9",
"cryptography==49.0.0",
"dotenv==0.9.9",
"idna==3.18",
"hdbcli==2.29.25",
"msal==1.37.0",
"pdfminer.six==20260107",
"pdfplumber==0.11.10",
"pillow==12.3.0",
"pycparser==3.0",
"PyJWT==2.13.0",
"pypdfium2==5.11.0",
"python-dotenv==1.2.2",
"requests==2.34.2",
"urllib3==2.7.0",
]
[project.optional-dependencies]
dev = [
"pytest==9.1.1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.setuptools]
py-modules = [
"adjuntos",
"config_validator",
"database_utilities",
"documentos",
"draft_sap",
"email_utilities",
"hana_manager",
"logger_manager",
"main",
"notificaciones",
"outlook_utilities",
"pdf_utilities",
"persistencia",
"pipeline",
"pipeline_utils",
"sapb1_manager",
"sunat_schema",
]
+1 -11
View File
@@ -1,16 +1,6 @@
certifi==2026.6.17
cffi==2.1.0
charset-normalizer==3.4.9
cryptography==49.0.0
dotenv==0.9.9
idna==3.18
hdbcli==2.29.25
msal==1.37.0
pdfminer.six==20260107
pdfplumber==0.11.10
pillow==12.3.0
pycparser==3.0
PyJWT==2.13.0
pypdfium2==5.11.0
python-dotenv==1.2.2
requests==2.34.2
urllib3==2.7.0
+300
View File
@@ -0,0 +1,300 @@
"""
Respuesta al proveedor: arma y envía el correo de aceptación o rechazo (plantillas
``plantilla/plantilla-aceptado.html`` y ``plantilla-rechazado.html``) a quien envió
el correo con el XML y el PDF.
Dos modos, según ``NOTIFICACION_MODO`` en el .env:
prueba (por defecto) la respuesta NO sale al proveedor: va al equipo
(``NOTIFICACION_EQUIPO``) con el asunto "[PRUEBA → remitente]".
real la respuesta va al remitente, con copia oculta al equipo.
Como el sistema contesta solo y a terceros, **no responde** a: remitentes
desconocidos o sin dominio válido, el propio buzón, direcciones ``no-reply`` y
afines, mensajes automáticos (respuestas automáticas, rebotes) y, en correos sin
comprobante, a respuestas (``Re:``) o mensajes que citan un código nuestro. Además
limita a ``MAX_RESPUESTAS_CORREO`` por remitente cada 24 h las respuestas a correos
sin comprobante.
"""
import logging
import os
import re
from datetime import datetime
from config_validator import MODOS_NOTIFICACION as MODOS
import observaciones as obs
import outlook_utilities
import plantillas
import seguimiento
from pipeline_utils import log
EQUIPO_POR_DEFECTO = ["jlopez@aberio.com.pe", "gcalle@pucp.pe"]
MAX_RESPUESTAS_CORREO = 3
_DIRECCION = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_AUTOMATICA = re.compile(r"no[-_.]?reply|do[-_.]?not[-_.]?reply|no[-_.]?responder|mailer-daemon|postmaster|bounce", re.I)
_CODIGO = re.compile(r"ABR-\d{6}-\d{5}")
_RESPUESTA = re.compile(r"^\s*(re|rv)\s*:", re.I)
# ============================================================================
# Configuración (.env)
# ============================================================================
def modo() -> str:
"""``prueba`` o ``real``. Cualquier otro valor (o ninguno) es ``prueba``."""
valor = os.getenv("NOTIFICACION_MODO", "prueba").strip().lower()
return valor if valor in MODOS else "prueba"
def equipo() -> list[str]:
"""Direcciones internas (``NOTIFICACION_EQUIPO``, separadas por coma o punto y coma)."""
lista = [d.strip() for d in re.split(r"[;,]", os.getenv("NOTIFICACION_EQUIPO", "")) if d.strip()]
return lista or list(EQUIPO_POR_DEFECTO)
def contacto_correo() -> str:
return os.getenv("NOTIFICACION_CONTACTO", "").strip() or os.getenv("EMAIL", "").strip()
def envio_correo() -> str:
"""Buzón al que el proveedor debe reenviar el comprobante corregido."""
return os.getenv("EMAIL", "").strip()
def logo_url() -> str:
return os.getenv("LOGO_URL", "").strip()
# ============================================================================
# Protecciones
# ============================================================================
def motivo_omision(email: dict, buzon: str, sin_comprobante: bool = False) -> str | None:
"""
Motivo por el que NO se debe responder a este correo, o None si se puede.
Args:
email: Dict del correo (``sender``, ``subject``, ``es_automatico``).
buzon: Dirección del buzón del sistema (``EMAIL``).
sin_comprobante: True si la respuesta es un rechazo de un correo sin comprobante válido.
"""
remitente = (email.get("sender") or "").strip()
if not remitente or remitente == "Desconocido":
return "remitente desconocido"
if not _DIRECCION.match(remitente):
return f"dirección no válida ({remitente})"
if remitente.lower() == buzon.strip().lower():
return "el remitente es el propio buzón"
if _AUTOMATICA.search(remitente.split("@")[0]):
return "dirección automática (no-reply)"
if email.get("es_automatico"):
return "mensaje automático (respuesta automática o rebote)"
if sin_comprobante:
asunto = email.get("subject") or ""
if _RESPUESTA.match(asunto) or _CODIGO.search(asunto):
return "parece una respuesta a una notificación nuestra"
return None
def _destinatarios(remitente: str) -> tuple[list[str], list[str], str]:
"""(para, copia oculta, prefijo del asunto) según el modo."""
if modo() == "real":
return [remitente], equipo(), ""
return equipo(), [], f"[PRUEBA → {remitente}] "
# ============================================================================
# Contexto de las plantillas
# ============================================================================
def _fecha_hora(email: dict) -> tuple[str, str]:
try:
dt = datetime.strptime(email["fecha"], "%Y-%m-%d %H:%M:%S")
except (KeyError, ValueError, TypeError):
dt = datetime.now()
return f"{dt:%d/%m/%Y}", f"{dt:%H:%M}"
def _comun(email: dict, registro: dict) -> dict:
fecha, hora = _fecha_hora(email)
return {
"logo_url": logo_url(),
"recepcion_fecha": fecha,
"recepcion_hora": hora,
"codigo_seguimiento": registro["codigo_seguimiento"],
"contacto_correo": contacto_correo(),
}
def _comprobante(factura: dict) -> dict:
numero = f"{factura.get('serie', '')}-{factura.get('numero', '')}"
tipo = "Factura electrónica" if factura.get("tipo") == "01" else factura.get("tipo_descripcion", "Comprobante")
return {
"comprobante_tipo_corto": "factura",
"comprobante_tipo": tipo,
"comprobante_numero": numero,
"proveedor_razon_social": factura.get("razon_social_emisor") or "—",
"proveedor_ruc": factura.get("ruc_emisor") or None,
}
def guias_para_plantilla(factura: dict, resultados_val: list[dict]) -> list[dict]:
"""
Filas de la tabla "Guías revisadas". La validación es por línea, así que todas las
guías de una línea llevan el mismo resultado; el importe de la línea se muestra una
vez (en su primera guía) y las demás dicen que están incluidas en la línea.
"""
filas = []
for i, linea in enumerate(factura.get("lineas", [])):
resultado = resultados_val[i] if i < len(resultados_val) else {}
guias = [it["guia"] for it in resultado.get("guias_lt", [])] or linea.get("guias_remision") or []
for j, guia in enumerate(guias):
filas.append({
"guia": f"{guia.get('serie', '')}-{guia.get('numero', '')}",
"importe_factura": obs.moneda(linea.get("total_linea")) if j == 0 else "incluida en la línea",
"conforme": bool(resultado.get("ok")),
})
return filas
def _fecha_estimada_pago(draft: dict) -> str | None:
"""Fecha de vencimiento del draft, solo si es posterior a la de contabilización (hay condición de pago)."""
try:
vencimiento = datetime.strptime(draft["DocDueDate"], "%Y-%m-%d")
contabilizacion = datetime.strptime(draft["DocDate"], "%Y-%m-%d")
except (KeyError, ValueError, TypeError):
return None
return f"{vencimiento:%d/%m/%Y}" if vencimiento > contabilizacion else None
def contexto_aceptado(email: dict, registro: dict, factura: dict, resultados_val: list[dict], draft: dict) -> dict:
comprobante = _comprobante(factura)
numero = comprobante["comprobante_numero"]
return {
**_comun(email, registro), **comprobante,
"asunto": f"Aceptado · Factura {numero} · {registro['codigo_seguimiento']}",
"preheader": f"Su factura {numero} pasó todas las validaciones. No necesita hacer nada más.",
"fecha_estimada_pago": _fecha_estimada_pago(draft),
"guias": guias_para_plantilla(factura, resultados_val),
}
def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict:
texto = obs.texto_observaciones(len(observaciones))
return {
"observaciones_texto": texto,
"preheader": f"Tiene {texto}. Corríjalas y envíenos el comprobante nuevamente.",
"observaciones": [
{"numero": n, "titulo": o.titulo, "como_corregir": o.como_corregir,
"filas": [{"etiqueta": e, "valor": v} for e, v in o.filas]}
for n, o in enumerate(observaciones, start=1)
],
"envio_correo": envio_correo(),
}
def contexto_fuera_de_linea(email: dict, registro: dict, factura: dict) -> dict:
"""Aviso de que SAP no está disponible: el comprobante quedó en cola y se procesará solo."""
comprobante = _comprobante(factura)
numero = comprobante["comprobante_numero"]
return {
**_comun(email, registro), **comprobante,
"asunto": f"En cola · Factura {numero} · {registro['codigo_seguimiento']}",
"preheader": f"Su factura {numero} quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
}
def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
observaciones: list[obs.Observacion]) -> dict:
comprobante = _comprobante(factura)
guias = guias_para_plantilla(factura, resultados_val)
return {
**_comun(email, registro), **comprobante, **_bloque_observaciones(observaciones),
"asunto": f"Rechazado · Factura {comprobante['comprobante_numero']} · {registro['codigo_seguimiento']}",
"guias": guias, "tiene_guias": bool(guias),
}
def contexto_rechazo_correo(email: dict, registro: dict, observaciones: list[obs.Observacion]) -> dict:
"""Rechazo de un correo sin comprobante identificable: no hay proveedor, número ni guías que mostrar."""
return {
**_comun(email, registro), **_bloque_observaciones(observaciones),
"asunto": f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}",
"comprobante_tipo_corto": "comprobante", "comprobante_tipo": "", "comprobante_numero": None,
"proveedor_razon_social": email.get("sender") or "—", "proveedor_ruc": None,
"guias": [], "tiene_guias": False,
}
# ============================================================================
# Envío
# ============================================================================
def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: str | None = None) -> None:
try:
seguimiento.registrar_notificacion(codigo, estado, error=error, destinatario=destinatario)
except Exception as ex:
log(f"⚠️ No se pudo registrar la notificación en seguimiento: {ex}", severity=logging.WARNING)
def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict,
sin_comprobante: bool = False, estado_exito: str = "enviada") -> str:
"""
Responde al remitente con la plantilla ``plantilla`` ("aceptado", "rechazado" o "fuera-de-linea").
Args:
estado_exito: Estado que se anota si el envío sale bien (``"en_espera"`` para el aviso
de servicio fuera de línea).
Returns:
El estado anotado en ``comprobantes``: ``estado_exito``, ``"fallida"`` u ``"omitida"``.
"""
codigo = registro["codigo_seguimiento"]
remitente = email.get("sender") or ""
motivo = motivo_omision(email, buzon, sin_comprobante)
if motivo is None and sin_comprobante and seguimiento.contar_respuestas_correo(remitente) >= MAX_RESPUESTAS_CORREO:
motivo = f"ya se respondió {MAX_RESPUESTAS_CORREO} veces a este remitente en 24 h"
if motivo:
log(f"⏭️ No se responde al proveedor: {motivo}", severity=logging.WARNING)
_anotar(codigo, "omitida", motivo)
return "omitida"
para, copia_oculta, prefijo = _destinatarios(remitente)
try:
html = plantillas.renderizar_plantilla(plantilla, contexto)
except plantillas.PlantillaError as ex:
log(f"❌ No se pudo armar la respuesta ({codigo}): {ex}", severity=logging.ERROR)
_anotar(codigo, "fallida", f"Plantilla: {ex}")
return "fallida"
enviado = outlook_utilities.send_email(
token, buzon, para, prefijo + contexto["asunto"], html, None, bcc_recipients=copia_oculta or None,
)
estado = estado_exito if enviado else "fallida"
log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
severity=logging.INFO if enviado else logging.ERROR)
_anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
return estado
def omitir_respuesta(registro: dict, motivo: str) -> None:
"""Deja anotado que a este comprobante no se le respondió, y por qué."""
_anotar(registro["codigo_seguimiento"], "omitida", motivo)
def encabezado_tecnico(email: dict, registro: dict | None) -> str:
"""Datos de contexto que se anteponen al correo técnico que recibe el equipo."""
codigo = registro["codigo_seguimiento"] if registro else "(sin código: falló el seguimiento)"
return (
f"<p><b>Remitente:</b> {email.get('sender', '')}<br>"
f"<b>Asunto:</b> {email.get('subject', '')}<br>"
f"<b>Código de seguimiento:</b> {codigo}<br>"
f"<b>Modo de notificación:</b> {modo()}</p>"
)
def enviar_a_equipo(token: str, buzon: str, asunto: str, html: str, adjuntos: list[str] | None = None) -> bool:
"""Envía el correo técnico (tablas de SAP, JSON adjuntos…) solo al equipo interno."""
destinos = equipo()
if not destinos:
return False
return outlook_utilities.send_email(token, buzon, destinos, asunto, html, adjuntos)
+193 -45
View File
@@ -1,4 +1,5 @@
import logging
import requests
import urllib3
import os
@@ -7,6 +8,8 @@ from typing import Optional, Dict, Any
from dotenv import load_dotenv
import hana_manager
logger = logging.getLogger(__name__)
# ============================================================================
# CONFIGURACIÓN
@@ -14,7 +17,8 @@ import hana_manager
urllib3.disable_warnings()
load_dotenv(dotenv_path=".env")
# Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
SAPB1_URL = os.getenv("SAP_B1_URL", "")
SAPB1_USER = os.getenv("SAP_B1_USER", "")
@@ -27,6 +31,15 @@ SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true"
_sap_session: dict = {"headers": None, "b1session": None, "routeid": None}
class SapNoDisponible(ConnectionError):
"""
SAP no responde: timeout, sin conexión o error 5xx. Es una caída de la
infraestructura, no un rechazo de SAP (un HTTP 4xx sigue siendo un
``ConnectionError`` normal). Subclase de ``ConnectionError``, así que el
código que ya captura ``ConnectionError`` o ``Exception`` no cambia.
"""
# ============================================================================
# LOGIN
# ============================================================================
@@ -36,17 +49,19 @@ def login() -> dict:
"UserName": SAPB1_USER,
"Password": SAPB1_PASSWORD,
}
print("🔐 Iniciando sesión en SAP B1 Service Layer...")
logger.info("🔐 Iniciando sesión en SAP B1 Service Layer...")
try:
resp = requests.post(
f"{SAPB1_URL}/Login",
json=payload,
timeout=SAPB1_TIMEOUT,
verify=SAPB1_VERIFY_SSL,
)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
raise SapNoDisponible(f"No se pudo conectar con SAP B1 para iniciar sesión: {exc}") from exc
if resp.status_code != 200:
raise ConnectionError(
f"Login fallido: HTTP {resp.status_code} - {resp.text}"
)
clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError
raise clase(f"Login fallido: HTTP {resp.status_code} - {resp.text}")
data = resp.json()
b1session = data["SessionId"]
@@ -54,7 +69,7 @@ def login() -> dict:
headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"}
_sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid})
print(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)")
logger.info(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)")
return headers
@@ -63,9 +78,9 @@ def login() -> dict:
# ============================================================================
def logout() -> None:
if not _sap_session["b1session"]:
print("⚠️ No hay sesión activa para cerrar.")
logger.warning("⚠️ No hay sesión activa para cerrar.")
return
print("🔓 Cerrando sesión SAP B1...")
logger.info("🔓 Cerrando sesión SAP B1...")
try:
resp = requests.post(
f"{SAPB1_URL}/Logout",
@@ -74,11 +89,11 @@ def logout() -> None:
verify=SAPB1_VERIFY_SSL,
)
if resp.status_code == 200:
print("✅ Sesión SAP B1 cerrada correctamente.")
logger.info("✅ Sesión SAP B1 cerrada correctamente.")
else:
print(f"⚠️ Logout respondió HTTP {resp.status_code}")
logger.warning(f"⚠️ Logout respondió HTTP {resp.status_code}")
except Exception as exc:
print(f"❌ Error al cerrar sesión: {exc}")
logger.error(f"❌ Error al cerrar sesión: {exc}")
finally:
_sap_session.update({"headers": None, "b1session": None, "routeid": None})
@@ -101,9 +116,9 @@ def _log_response(method: str, endpoint: str, response: requests.Response, paylo
f" ({len(response.content)} bytes)"
)
if response.status_code >= 400:
print("⚠️ %s - %s", log_line, response.text[:300])
logger.warning(f"⚠️ {log_line} - {response.text[:300]}")
else:
print(log_line)
logger.info(log_line)
# ============================================================================
@@ -132,7 +147,7 @@ def forward_to_sapb1(
if extra_headers:
req_headers.update(extra_headers)
print(f" ├── 🔄 [{method}] {endpoint_path}")
logger.info(f" ├── 🔄 [{method}] {endpoint_path}")
try:
resp = requests.request(
@@ -145,14 +160,14 @@ def forward_to_sapb1(
verify=SAPB1_VERIFY_SSL,
)
except requests.exceptions.Timeout:
raise ConnectionError(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
raise SapNoDisponible(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
except requests.exceptions.ConnectionError as exc:
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
raise SapNoDisponible(f"No se pudo conectar con SAP B1: {exc}")
except requests.RequestException as exc:
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
if resp.status_code == 401:
print("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...")
logger.warning("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...")
req_headers["Cookie"] = login()["Cookie"]
resp = requests.request(
method=method,
@@ -176,7 +191,8 @@ def forward_to_sapb1(
)
except (json.JSONDecodeError, AttributeError):
sap_message = resp.text
raise ConnectionError(
clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError
raise clase(
f"Error en SAP {method} {endpoint_path}: "
f"HTTP {resp.status_code} - {sap_message}"
)
@@ -190,71 +206,154 @@ def forward_to_sapb1(
def create_draft(payload: dict) -> dict:
resp = forward_to_sapb1("POST", "Drafts", json_data=payload)
result = resp.json()
print(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
logger.info(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
return result
# ============================================================================
# ¿LA FACTURA YA EXISTE EN SAP?
# ============================================================================
def _literal_odata(valor: Any) -> str:
"""Valor de texto para un $filter de OData: comillas simples duplicadas."""
return str(valor).replace("'", "''")
def buscar_factura_existente(ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict | None:
"""
Busca una factura de proveedor en SAP B1 por proveedor (``PL`` + RUC), tipo, serie
y número (``U_BPP_MDTD``/``U_BPP_MDSD``/``U_BPP_MDCD``, los mismos campos que llena
el draft). Mira dos lugares: las facturas de compra **no anuladas** y los **drafts**
de factura de compra (una factura cuyo draft ya se creó, aunque nadie lo haya
contabilizado todavía).
Returns:
dict | None — ``{"entidad": "PurchaseInvoices" | "Drafts", "DocEntry": int, "DocNum": int}``
del primer documento encontrado, o None si la factura no existe.
Raises:
SapNoDisponible: Si SAP no responde (no se puede afirmar que no exista).
"""
base = (
f"CardCode eq 'PL{_literal_odata(ruc_emisor)}'"
f" and U_BPP_MDTD eq '{_literal_odata(tipo)}'"
f" and U_BPP_MDSD eq '{_literal_odata(serie)}'"
f" and U_BPP_MDCD eq '{_literal_odata(numero)}'"
)
for entidad, condicion in (
("PurchaseInvoices", " and Cancelled eq 'tNO'"),
("Drafts", " and DocObjectCode eq 'oPurchaseInvoices'"),
):
resp = forward_to_sapb1(
"GET", entidad,
params={"$filter": base + condicion, "$select": "DocEntry,DocNum", "$top": "1"},
)
encontrados = resp.json().get("value", [])
if encontrados:
logger.info(f" ├── 🔎 {serie}-{numero} ya existe en SAP ({entidad} {encontrados[0].get('DocEntry')})")
return {"entidad": entidad, "DocEntry": encontrados[0].get("DocEntry"), "DocNum": encontrados[0].get("DocNum")}
return None
# ============================================================================
# OBTENER FACTURA DE COMPRA (GET /PurchaseInvoices(DocEntry))
# ============================================================================
def get_purchase_invoice(doc_entry: int) -> dict:
resp = forward_to_sapb1("GET", f"PurchaseInvoices({doc_entry})")
result = resp.json()
print(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
logger.info(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
return result
# ============================================================================
# OBTENER GUIA (GET /DeliveryNotes)
# Tabla HANA de origen → entidad de Service Layer que hay que consultar.
# ODLN (Entregas) se expone en SL como DeliveryNotes; OWTR (Traslados de
# Inventario) se expone como StockTransfers.
# ============================================================================
_TABLA_A_ENTIDAD_SL = {
"ODLN": "DeliveryNotes",
"OWTR": "StockTransfers",
}
# ============================================================================
# OBTENER GUIA (GET /DeliveryNotes o /StockTransfers)
# ============================================================================
def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
"""
Obtiene DeliveryNotes de SAP B1.
Obtiene el documento completo de SAP B1 a partir del cual se validan los
precios de flete (líneas ``FL*`` en ``DocumentLines``).
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 1 — Busca DocEntry/DocNum/CardCode/CardName/Entidad:
- Primero intenta Service Layer con $select (entidad DeliveryNotes).
- Si no hay resultados, fallback a HANA (consulta ODLN y, si no
encuentra nada, OWTR).
Fase 2 — Por cada DocEntry obtenido, hace GET /DeliveryNotes({DocEntry})
para recuperar el documento completo.
Fase 2 — Por cada DocEntry obtenido, hace GET a la entidad indicada en
"Entidad" (DeliveryNotes o StockTransfers).
- Si es DeliveryNotes, ese documento ya trae ``DocumentLines`` con
las líneas FL* y se retorna tal cual.
- Si es StockTransfers, ese documento es un traslado de inventario
y NO tiene líneas FL* ni precios. En ese caso se lee el campo
``U_IDC_NUMOV`` (DocEntry de la Orden de Venta asociada) y se
hace GET a ``Orders(U_IDC_NUMOV)``, devolviendo ese documento en
su lugar para que la validación de precios funcione igual que
con DeliveryNotes.
Returns:
list[dict] — Documentos completos de DeliveryNotes, o [] si no se
encontró nada en ninguna fuente.
list[dict] — Documentos completos, o [] si no se encontró nada en
ninguna fuente.
Raises:
SapNoDisponible: Si Service Layer no responde y HANA no encontró nada
(no se puede afirmar que la guía no exista).
"""
codigo = f"{serie}{tipo}-{numero}"
codigo = f"{tipo}{serie}-{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)")
logger.warning(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"]
entidad = entry["Entidad"]
try:
resp = forward_to_sapb1("GET", f"DeliveryNotes({doc_entry})")
resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})")
doc = resp.json()
logger.info(f" │ └─ {entidad}({doc_entry}) obtenido")
if entidad == "StockTransfers":
num_ov = doc.get("U_IDC_NUMOV")
if not num_ov:
logger.warning(f" │ ⚠️ StockTransfers({doc_entry}) sin U_IDC_NUMOV: no se puede validar precios")
continue
resp_ov = forward_to_sapb1("GET", f"Orders({num_ov})")
doc = resp_ov.json()
logger.info(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))")
docs.append(doc)
print(f" │ └─ DeliveryNote({doc_entry}) obtenido")
except SapNoDisponible:
raise
except Exception as e:
print(f" │ ⚠️ Error obteniendo DeliveryNote({doc_entry}): {e}")
logger.warning(f" │ ⚠️ Error obteniendo {entidad}({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}"
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...]."""
codigo = f"{tipo}{serie}-{numero}"
service_layer_caido = None
# ── 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 U_BPP_MDTD eq '{tipo}'"
f" and U_BPP_MDSD eq '{serie}'"
f" and contains(U_BPP_MDCD,'{numero}')"
)
resp = forward_to_sapb1(
@@ -267,26 +366,75 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
)
sl_entries = resp.json().get("value", [])
if sl_entries:
print(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
for sl_entry in sl_entries:
sl_entry["Entidad"] = "DeliveryNotes"
logger.info(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
return sl_entries
print(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
logger.info(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
except Exception as e:
print(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
if isinstance(e, SapNoDisponible):
service_layer_caido = e
logger.warning(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")
for hana_entry in hana_entries:
hana_entry["Entidad"] = _TABLA_A_ENTIDAD_SL.get(
hana_entry.get("Tabla"), "DeliveryNotes"
)
logger.info(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados")
return hana_entries
print(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
logger.info(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
except Exception as e:
print(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
logger.warning(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
if service_layer_caido is not None:
# SL no respondió y HANA no aportó nada: no es "la guía no existe", es una caída
raise SapNoDisponible(f"Service Layer no disponible y HANA sin resultados para la guía {codigo}: {service_layer_caido}")
return []
# ============================================================================
# OBTENER CONDICIÓN DE PAGO (GET /BusinessPartners + /PaymentTermsTypes)
# ============================================================================
def obtener_condicion_pago(card_code: str) -> dict | None:
"""
Obtiene la condición de pago (PaymentTermsTypes) de un Business Partner,
para calcular la fecha de vencimiento de un documento.
Args:
card_code: Código del Business Partner (ej. "PL20555929006").
Returns:
dict | None — {"GroupNumber", "PaymentTermsGroupName",
"NumberOfAdditionalMonths", "NumberOfAdditionalDays"}, o None si el
CardCode no existe, no tiene condición de pago asociada, o falló la
consulta a Service Layer.
"""
try:
resp_bp = forward_to_sapb1(
"GET", f"BusinessPartners('{card_code}')",
params={"$select": "CardCode,PayTermsGrpCode"},
)
group_num = resp_bp.json().get("PayTermsGrpCode")
if group_num is None:
return None
resp_terms = forward_to_sapb1("GET", f"PaymentTermsTypes({group_num})")
terms = resp_terms.json()
return {
"GroupNumber": terms.get("GroupNumber"),
"PaymentTermsGroupName": terms.get("PaymentTermsGroupName"),
"NumberOfAdditionalMonths": terms.get("NumberOfAdditionalMonths") or 0,
"NumberOfAdditionalDays": terms.get("NumberOfAdditionalDays") or 0,
}
except Exception as e:
logger.warning(f" │ ⚠️ Error obteniendo condición de pago de {card_code}: {e}")
return None
# ============================================================================
# OBTENER ZONAS (GET /U_IDC_ZONAS)
# ============================================================================
@@ -317,5 +465,5 @@ def get_zonas() -> list:
)
result = resp.json()
value = result.get("value", [])
print(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)")
logger.info(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)")
return value
+226
View File
@@ -0,0 +1,226 @@
"""
Registro de comprobantes y códigos de seguimiento.
Cada comprobante que llega por correo recibe un ``codigo_seguimiento`` (por
ejemplo ``ABR-261019-00042``) que se comunica al proveedor y que sirve para
identificarlo si lo reenvía. El registro también guarda el resultado del
proceso (aceptado, rechazado o error) y el estado de la notificación enviada.
Un comprobante se identifica por (RUC emisor, tipo, serie, número): si vuelve
a llegar, en el mismo correo o en otro, conserva su código. Los correos que no
traen ningún comprobante identificable (sin adjuntos, sin XML válido…) se
registran aparte con ``registrar_correo``: una fila por correo, sin serie ni
número.
El esquema se crea en ``database_utilities.init_db``.
"""
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta
import database_utilities
PREFIJO_CODIGO = "ABR"
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
# en_espera: se avisó al proveedor que el servicio está fuera de línea y el comprobante se reintentará
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida", "en_espera")
# ============================================================================
@contextmanager
def _conexion():
"""Conexión con filas tipo diccionario; confirma al salir o revierte si hubo error."""
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _ahora() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _a_dict(fila: sqlite3.Row | None) -> dict | None:
if fila is None:
return None
registro = dict(fila)
if registro.get("observaciones") is not None:
registro["observaciones"] = json.loads(registro["observaciones"])
return registro
def _insertar(conn: sqlite3.Connection, email_id: str, ruc_emisor, tipo, serie, numero) -> dict:
"""Inserta la fila y le asigna el código, que se deriva de su ``id`` (único por construcción)."""
ahora = _ahora()
cursor = conn.execute(
"""INSERT INTO comprobantes (email_id, ruc_emisor, tipo, serie, numero, creado_en, actualizado_en)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(email_id, ruc_emisor, tipo, serie, numero, ahora, ahora),
)
codigo = f"{PREFIJO_CODIGO}-{datetime.now():%y%m%d}-{cursor.lastrowid:05d}"
conn.execute("UPDATE comprobantes SET codigo_seguimiento = ? WHERE id = ?", (codigo, cursor.lastrowid))
return _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (cursor.lastrowid,)).fetchone())
# ============================================================================
def registrar_comprobante(email_id: str, ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict:
"""
Registra un comprobante, o recupera el registro existente si ya había llegado.
Si el comprobante ya existía y llega en un correo distinto, se conserva su
código, se actualiza ``email_id`` al correo más reciente y se incrementa
``veces_recibido``. Si es el mismo correo (reproceso), no cambia nada.
Returns:
dict — La fila de ``comprobantes`` (con ``codigo_seguimiento``) más la
clave ``reenvio``: True si el comprobante ya había llegado en otro correo.
"""
with _conexion() as conn:
fila = conn.execute(
"SELECT * FROM comprobantes WHERE ruc_emisor = ? AND tipo = ? AND serie = ? AND numero = ?",
(ruc_emisor, tipo, serie, numero),
).fetchone()
if fila is None:
registro = _insertar(conn, email_id, ruc_emisor, tipo, serie, numero)
registro["reenvio"] = False
return registro
reenvio = fila["email_id"] != email_id
if reenvio:
conn.execute(
"""UPDATE comprobantes
SET email_id = ?, veces_recibido = veces_recibido + 1, actualizado_en = ?
WHERE id = ?""",
(email_id, _ahora(), fila["id"]),
)
registro = _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (fila["id"],)).fetchone())
registro["reenvio"] = reenvio
return registro
def registrar_correo(email_id: str) -> dict:
"""
Registra un correo que no trae ningún comprobante identificable (una fila
por correo, sin RUC, serie ni número) y devuelve su registro. Idempotente.
"""
with _conexion() as conn:
fila = conn.execute(
"SELECT * FROM comprobantes WHERE email_id = ? AND serie IS NULL", (email_id,)
).fetchone()
if fila is not None:
return _a_dict(fila)
return _insertar(conn, email_id, None, None, None, None)
def obtener(codigo_seguimiento: str) -> dict | None:
"""Retorna el registro con ese código, o None si no existe."""
with _conexion() as conn:
return _a_dict(conn.execute(
"SELECT * FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
).fetchone())
def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: list | None = None,
doc_entry: int | None = None) -> None:
"""
Guarda el resultado del proceso de un comprobante.
Args:
codigo_seguimiento: Código devuelto por ``registrar_comprobante``/``registrar_correo``.
estado: Uno de ``ESTADOS``.
observaciones: Lista serializable a JSON (motivos de rechazo o detalle del error).
doc_entry: DocEntry del draft creado en SAP, si corresponde.
Raises:
ValueError: Si ``estado`` no es válido.
LookupError: Si no existe el código.
"""
if estado not in ESTADOS:
raise ValueError(f"Estado inválido: {estado!r} (válidos: {', '.join(ESTADOS)})")
with _conexion() as conn:
cursor = conn.execute(
"""UPDATE comprobantes
SET estado = ?, observaciones = ?, doc_entry = ?, actualizado_en = ?
WHERE codigo_seguimiento = ?""",
(estado, None if observaciones is None else json.dumps(observaciones, ensure_ascii=False),
doc_entry, _ahora(), codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None,
destinatario: str | None = None) -> None:
"""
Guarda el estado de la notificación enviada al proveedor.
Args:
codigo_seguimiento: Código del registro.
estado: Uno de ``ESTADOS_NOTIFICACION``.
error: Motivo, si la notificación falló o se omitió.
destinatario: Direcciones a las que se envió realmente (en modo prueba no son las del remitente).
Raises:
ValueError: Si ``estado`` no es válido.
LookupError: Si no existe el código.
"""
if estado not in ESTADOS_NOTIFICACION:
raise ValueError(f"Estado de notificación inválido: {estado!r} (válidos: {', '.join(ESTADOS_NOTIFICACION)})")
ahora = _ahora()
with _conexion() as conn:
cursor = conn.execute(
"""UPDATE comprobantes
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?,
notificacion_destinatario = ?, actualizado_en = ?
WHERE codigo_seguimiento = ?""",
(estado, error, ahora, destinatario, ahora, codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
def registrar_intento(codigo_seguimiento: str) -> int:
"""
Suma un intento fallido de procesar el comprobante (SAP fuera de línea) y devuelve el total.
Raises:
LookupError: Si no existe el código.
"""
with _conexion() as conn:
cursor = conn.execute(
"UPDATE comprobantes SET intentos = intentos + 1, actualizado_en = ? WHERE codigo_seguimiento = ?",
(_ahora(), codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
return conn.execute(
"SELECT intentos FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
).fetchone()[0]
def contar_respuestas_correo(remitente: str, horas: int = 24) -> int:
"""
Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por
correos **sin comprobante** (sin serie ni número). Sirve de freno contra bucles
y contra remitentes que insisten con correos inválidos.
"""
desde = (datetime.now() - timedelta(hours=horas)).strftime("%Y-%m-%d %H:%M:%S")
with _conexion() as conn:
return conn.execute(
"""SELECT COUNT(*) FROM comprobantes c JOIN emails e ON e.id = c.email_id
WHERE c.serie IS NULL AND c.notificacion_estado = 'enviada'
AND c.notificacion_fecha >= ? AND lower(e.sender) = lower(?)""",
(desde, remitente),
).fetchone()[0]
+3 -2
View File
@@ -32,6 +32,7 @@ GUIAS_NOTES_PATTERNS = [
# 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+REFER\.?\s*:?\s*(?:S/\.?\s*)?([\d,]+\.\d+)", # "VALOR REFER\t:S/ 1431.16"
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"
@@ -128,7 +129,7 @@ class DatosComprobante:
refs_str = ""
if self.documentos_referencia:
refs_str = "\n Documentos Ref: " + ", ".join(
f"{ref.id} (Tipo {ref.tipo})" for ref in self.documentos_referencia
f"{ref.serie}-{ref.numero} (Tipo {ref.tipo})" for ref in self.documentos_referencia
)
detraccion_str = ""
if self.tiene_detraccion:
@@ -686,7 +687,7 @@ def extraer_datos_xml(ruta_xml: str) -> Optional[dict]:
return datos.to_dict()
if "DespatchAdvice" in tag:
tipo = _find_text(root, "desp:DespatchAdviceTypeCode", NAMESPACES) or _find_text(root, "cbc:DespatchAdviceTypeCode", NAMESPACES) or ""
if tipo in ["31"]:
if tipo in ("09", "31"):
datos = _parse_despatch_advice(root)
return datos.to_dict()
return None
+36
View File
@@ -0,0 +1,36 @@
import os
import pytest
import database_utilities
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES = os.path.join(RAIZ, "tests", "fixtures", "correos_prueba")
GOLDEN = os.path.join(RAIZ, "tests", "golden")
@pytest.fixture
def actualizar_golden() -> bool:
"""True si se ejecutó con ``UPDATE_GOLDEN=1`` (regenera los resultados esperados)."""
return os.environ.get("UPDATE_GOLDEN") == "1"
@pytest.fixture(autouse=True)
def base_de_datos_temporal(tmp_path_factory, monkeypatch):
"""
Toda prueba usa una BD SQLite propia: ``DB_NAME`` es relativo al directorio
de trabajo, así que sin esto se escribiría en el ``emails.db`` real.
"""
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
database_utilities.init_db()
@pytest.fixture(autouse=True)
def entorno_de_notificacion_aislado(monkeypatch):
"""
Los módulos cargan el ``.env`` real al importarse. Las pruebas no deben depender de él:
sin esto, un ``NOTIFICACION_MODO=real`` del .env haría que se probara (y enviara) en modo real.
"""
for variable in ("NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "NOTIFICACION_CONTACTO", "LOGO_URL"):
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("EMAIL", "buzon@aberio.com")
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:biz="urn:bizlinks:names:specification:ubl:peru:schema:xsd:BizlinksAggregateComponents-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1"><ext:UBLExtensions><ext:UBLExtension><cbc:ID>EBIZ</cbc:ID><ext:ExtensionContent><biz:AdditionalInformation><biz:AdditionalProperty><cbc:ID>8025</cbc:ID><cbc:Value><![CDATA[473-4883 473-2554 323-3702]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8024</cbc:ID><cbc:Value><![CDATA[ventas@dansa.com.pe]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8023</cbc:ID><cbc:Value><![CDATA[www.grupodansa.com.pe]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9670</cbc:ID><cbc:Value><![CDATA[MATERIALES DE CONSTRUCCION]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9673</cbc:ID><cbc:Value><![CDATA[Nro.Cuota = 1 Fecha de Vencimiento = 2026-06-15 Tipo de Moneda = PEN Monto de Cuota = 707.31]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8185</cbc:ID><cbc:Value><![CDATA[CREDITO A 20 DIAS]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9043</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9049</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9218</cbc:ID><cbc:Value><![CDATA[CABELLO HUARCAYA CRISTY JOICE]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9130</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9099</cbc:ID><cbc:Value><![CDATA[26/05/2026]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9925</cbc:ID><cbc:Value><![CDATA[20100029741]]></cbc:Value></biz:AdditionalProperty></biz:AdditionalInformation></ext:ExtensionContent></ext:UBLExtension><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="signatureKG"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>bCmAYPIFk/DmyDKNUBWpbxlZbWw=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>g8i0bVVbwVuKrs56/vuS4paPFSKrp5jAq3mTf6kF31LDU48wOWVOIpn2/B2Y06Tvze3DFqowxlhod76lGXWd4mEQ1HiFZoITESGbh7ddNlUh/VAIgBxtZdvxUolFanuZzOYqqVGXCnAtd+ADTU/6UVz/bsJ0ONdsbOzqu4xtDU8HJm3fd7KpVabELgOQHKTNP690vGAP/+Jm9/eFMZURHPeoPO/9wYQyy+YFgTSnX5+kSKVFwicnP00hBPE08/E/WbJ6BAKB9eO/LzY+sjG8HQODp9zdAfuY3TjM/6Yy5tz+04fBE/E+mDsimHZ4FKd0/K84X1zE79Rmep6eUL0HvQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIGJTCCBQ2gAwIBAgITMwAAab3LtCvmGjz0EQAAAABpvTANBgkqhkiG9w0BAQsFADCBkjELMAkGA1UEBhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxJjAkBgNVBAsTHUNvcHlyaWdodCAoYykgMjAxNiBXSVNlS2V5IFNBMRYwFAYDVQQLEw1JbnRlcm5hdGlvbmFsMTEwLwYDVQQDEyhXSVNlS2V5IENlcnRpZnlJRCBBZHZhbmNlZCBTZXJ2aWNlcyBDQSA0MB4XDTIzMDQyNDE2MTEzNFoXDTI2MDQyMzE2MTEzNFowgesxCzAJBgNVBAYTAlBFMQ0wCwYDVQQHEwRMSU1BMRgwFgYDVQQKEw9CSVpMSU5LUyBTLkEuQy4xLzAtBgNVBAsMJlZhbGlkYXRlZCBieSBaeVRydXN0IFNvY2llZGFkIEFuw7NuaW1hMRQwEgYDVQQLEwsyMDQ3ODAwNTAxNzEVMBMGA1UECxMMRE5JIDA3ODYxNDU2MSwwKgYDVQQDEyNKQVZJRVIgRkVSTkFORE8gRFJBWEwgR0FSQ0lBIFJPU0VMTDEnMCUGCSqGSIb3DQEJARYYamF2aWVyLmRyYXhsQGJpemxpbmtzLmxhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv4O4GDlvAP4xTbCAjfQAFKjqPJX6wrJ439SvsYbEDHE1QBw191JJiUCA8diVqyJC3f9q5zPMR84+Gpscf1njSPOpxqBK+hU28mR/0lbV31fVKTOeZ0OWVepKTlbmdxf3c4GFVWQownyrD6rzdWC1fpFi3+g3ibkDLXbTVzMACwp5SG8AEmalY/aDEEReDhJjK9ljl3s/v2YnF2Rdv03A9zdRmQ3UT/xYfAAnWp7G7b0G2Fa5QirH7WVxcV0UKuef8ajl7hqdHJy7CBBIAX0OllbqET8rwnu19MzjN7hu1mwqEk6sQeRb3EJIs0GH1fLVS7s0O162s/fSAgwfJwyNVQIDAQABo4ICFzCCAhMwHQYDVR0OBBYEFJnLNJRPWOgtMEdxj/pfn/8eJ+6TMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBT05JtX0q7CnohNALorr2XJY6mHmzA8BgNVHR8ENTAzMDGgL6AthitodHRwOi8vcHVibGljLndpc2VrZXkuY29tL2NybC93Y2lkYXNjYTQuY3JsMG0GCCsGAQUFBwEBBGEwXzA3BggrBgEFBQcwAoYraHR0cDovL3B1YmxpYy53aXNla2V5LmNvbS9jcnQvd2NpZGFzY2E0LmNydDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3Aud2lzZWtleS5jb20vMCkGA1UdJQQiMCAGCCsGAQUFBwMCBgorBgEEAYI3CgMMBggrBgEFBQcDBDA1BgkrBgEEAYI3FQoEKDAmMAoGCCsGAQUFBwMCMAwGCisGAQQBgjcKAwwwCgYIKwYBBQUHAwQwIwYDVR0RBBwwGoEYamF2aWVyLmRyYXhsQGJpemxpbmtzLmxhMEQGCSqGSIb3DQEJDwQ3MDUwDgYIKoZIhvcNAwICAgCAMA4GCCqGSIb3DQMEAgIAgDAHBgUrDgMCBzAKBggqhkiG9w0DBzBHBLine truncated
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<DespatchAdvice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent><ds:Signature Id="signatureFACTURALOPERU">
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>jh2FnXCc6mcZFOLNh4yidKCBNOE=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>QkBKgCtC1pMy8xW4kF227KpiRiTSqwS9rmx4Ja1NHcnyh+2CYOLoeWRX1CsfGZe2Twayxe79XojFYvFiDsl7uM6PUySq7BB1OdBj6ZAR4TJYLUQXuBBXRFL2DI4j6Btuu/q8AIPHmrJepk/2tWJqb/oiBo2FhoGdEXCmkyZgHNAIIiRYQD7l00GhDVolirDrkii6zo/grWNFUGZC95SRFxgeQF23gED2PwakIzzMOqQrb/7IWh0er+ZF+Vk8QB8Y1CGHtRVytKMX+sT4K+G4O/tTT5GCQizs9HZjN5kxB1VMtM4GlUy4dpWuVblZKLREpZ9RFP2glThrohebgTGq1g==</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFbDCCBFSgAwIBAgIIFUldkkH9yjEwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMjAyMTM1OTU0WhcNMjcwMjAyMTM1OTAwWjCCARMxMTAvBgNVBAkMKExULiAyMCBNWi4gQiBVUkIuIFJFU0lELiBIT05PUiBZIExFQUxUQUQxITAfBgkqhkiG9w0BCQEWEm1hbGlhZ2FAbWlnYWZpLmNvbTEsMCoGA1UEAwwjU09GVFdBUkUgREUgRkFDVFVSQUNJT04gRUxFQ1RST05JQ0ExLDAqBgNVBAsMI0ZBQ1RVUkEgRUxFQ1RST05JQ0EgUlVDIDIwNTA2Njg5MzI2MSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxIDAeBgNVBAoMF01BVEVSSUFMRVMgRC9DQVNBIFMuQS5DMQ0wCwYDVQQHDARMSU1BMQswCQYDVQQGEwJQRTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOej26BfjO7htv3e1lPaj6I4rzJanTTGMUkD2fkEt4PLMnb7AWrht9cDj0SvqofT8w56jrlqLjV2GpsVgxMTUe+c5Eir2IO+IL8pQva1TFRwpzWCseyb5by1igpCdNKTeJH34DkoRLogc4c2pqIKtWYsPX3QsjEy0rjW6+7sX/JNAQcgrzgJWw4WZwIIjK3PkF7s1O0VqLjTaZhHhbP9bPkMO8u+0RogjKZvBGfKY6ftEMjaPqTtB6k6zbZx7pPk+bRNMjSOncIFBehbaHYt63CeDlJHDNf5u+4jSWgp5I4nxHggfMYmeHOZGiOGYh6hClITOq+/Mg67g5FUl9416/UCAwEAAaOCAY0wggGJMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUXYhbretl+/5oouTZbz1XcqLvK5kwZwYIKwYBBQUHAQEEWzBZMDUGCCsGAQUFBzAChilodHRwOi8vY3J0LmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNlcjAgBggrBgEFBQcwAYYUaHR0cDovL29jc3AubGxhbWEucGUwHQYDVR0RBBYwFIESbWFsaWFnYUBtaWdhZmkuY29tMEYGA1UdIAQ/MD0wOwYNKwYBBAGDl3cAAQADATAqMCgGCCsGAQUFBwIBFhxodHRwczovL2xsYW1hLnBlL3JlcG9zaXRvcnkvMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsLmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNybDAdBgNVHQ4EFgQUTtWOjRJrY+hai0Np8dEDrqh3tIcwDgYDVR0PAQH/BAQDAgbAMA0GCSqGSIb3DQEBCwUAA4IBAQCIe0oUU2YJTZY0jULGUgE09eyyE+qh+V6MZLGPyeieuM+sS7U2uqoctQLS2VVIbx+pPoJ/ubmDS4zxMgxejcyJwiW0u9cn1bUS7XxQ4W5lSdRY5vi1MOTB2PXE3h1ESB6T6EJcY4P+azSNXgxOtnUzeXRxGSOR2RcoSiBocfJWKWjcOrVyJ4hksLKhjjm7hwi4zWD4chiJfaaHx2an9vpx8/gZKy4zz4+q1kDSoXL4FqY7DWGtyxWJVW/J2nbTj8PdV6YDwkboLPPwFhDtN0OE7VBqpXIItxqXLGPWupL7PSnhYDAB1uDxWbfkBx7WtIpKMxT7zvmwzaTTlvEOgqnH</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>V002-11</cbc:ID>
<cbc:IssueDate>2026-05-11</cbc:IssueDate>
<cbc:IssueTime>13:05:50</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT">31</cbc:DespatchAdviceTypeCode>
<!-- DOCUMENTOS ADICIONALES (Catalogo D41) -->
<cac:AdditionalDocumentReference>
<cbc:ID>T001-128662</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo61">09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de remisión remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>T001-128661</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo61">09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de remisión remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:Signature>
<cbc:ID>signatureFACTURALOPERU</cbc:ID>
<cbc:Note>FACTURALO</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>#signatureFACTURALOPERU</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<!-- DATOS DEL EMISOR (TRANSPORTISTA) -->
<cac:DespatchSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<!-- DATOS DEL RECEPTOR (DESTINATARIO) -->
<cac:DeliveryCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<!-- DATOS DE QUIEN PAGA EL SERVICIO -->
<!-- DATOS DEL TRASLADO -->
<cac:Shipment>
<!-- ID OBLIGATORIO POR UBL -->
<cbc:ID>SUNAT_Envio</cbc:ID>
<!-- PESO BRUTO TOTAL DE LA CARGA-->
<cbc:GrossWeightMeasure unitCode="KGM">31875.00</cbc:GrossWeightMeasure>
<!-- INDICADORES -->
<cac:ShipmentStage>
<!-- FECHA DE INICIO DEL TRASLADO -->
<cac:TransitPeriod>
<cbc:StartDate>2026-05-11</cbc:StartDate>
</cac:TransitPeriod>
<!-- CONDUCTOR PRINCIPAL -->
<cac:DriverPerson>
<!-- TIPO Y NUMERO DE DOCUMENTO DE IDENTIDAD -->
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="1">09715404</cbc:ID>
<!-- NOMBRES -->
<cbc:FirstName>HUAMAN ÑAHUE, JUAN</cbc:FirstName>
<!-- APELLIDOS -->
<cbc:FamilyName>DE DIOS</cbc:FamilyName>
<!-- TIPO DE CONDUCTOR: PRINCIPAL -->
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<!-- LICENCIA DE CONDUCIR -->
<cbc:ID>Q09715404</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
<!-- CONDUCTOR SECUNDARIO -->
</cac:ShipmentStage>
<cac:Delivery>
<!-- DIRECCION DEL PUNTO DE LLEGADA -->
<cac:DeliveryAddress>
<!-- UBIGEO DE LLEGADA -->
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150502</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[CA. PANAMERICANA SUR KM 98 SANTA CRUZ]]></cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<!-- DIRECCION DEL PUNTO DE PARTIDA -->
<cac:DespatchAddress>
<!-- UBIGEO DE PARTIDA -->
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<!-- DIRECCION COMPLETA Y DETALLADA DE PARTIDA -->
<cac:AddressLine>
<cbc:Line><![CDATA[AV. ATOCONGO 1860]]></cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<!-- DATOS DEL REMITENTE -->
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA S.A.C.]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<!-- NUMERO DE CONTENEDOR -->
<cbc:ID>-</cbc:ID>
<cac:TransportEquipment>
<!-- VEHICULO PRINCIPAL -->
<!-- PLACA - VEHICULO PRINCIPAL -->
<cbc:ID>F6P722</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>15M24026972E</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
<!-- VEHICULOS SECUNDARIOS -->
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<!-- DETALLES DE BIENES A TRASLADAR -->
<cac:DespatchLine>
<!-- NUMERO DE ORDEN DEL ITEM -->
<cbc:ID>1</cbc:ID>
<!-- CANTIDAD -->
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">550.0000</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description><![CDATA[BOLSA CEMENTO APU]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID><![CDATA[10000540]]></cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
<cac:DespatchLine>
<!-- NUMERO DE ORDEN DEL ITEM -->
<cbc:ID>2</cbc:ID>
<!-- CANTIDAD -->
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">200.0000</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description><![CDATA[BOLSA CEMENTO ANDINO ULTRA HS]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID><![CDATA[10003615]]></cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
</DespatchAdvice>
@@ -0,0 +1,114 @@
[
{
"ruc_emisor": "10060028686",
"razon_social_emisor": "DANSA",
"fecha_emision": "2026-05-26",
"tipo": "01",
"serie": "F002",
"numero": "2450",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 736.78,
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": "027",
"porcentaje_detraccion": 4.0,
"monto_detraccion": 29.47,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERV.TRANS.550 BLS ULTRA HSX1.3396",
"cantidad": 1.0,
"precio_unitario": 736.78,
"total_linea": 624.38,
"notes_text": "DE VEHICULO DE PLACA D8U934/D1L982 | P.PARTIDA:AV. ATOCONGO 1860-VMT | P.LLEGADA:MZ.P.LT 20 URB.NUEVO LURIN LT 21 | GRE REMITENTE: T001-00128912 | GRT:V001-00000194 | CONF.VEH T3S3 CARGA 23,375TN | VALOR REFERENCIAL S/736.78 | UND | -",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "128912"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V001",
"numero": "194"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/001_correo/10060028686-01-F002-2450.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20506689326",
"razon_social_emisor": "MATERIALES D`CASA S.A.C",
"fecha_emision": "2026-05-11",
"tipo": "31",
"serie": "V002",
"numero": "11",
"ruc_receptor": "20506689326",
"razon_social_receptor": "MATERIALES D`CASA S.A.C",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "128662"
},
{
"tipo": "09",
"serie": "T001",
"numero": "128661"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860",
"punto_llegada": "CA. PANAMERICANA SUR KM 98 SANTA CRUZ",
"peso_bruto": "31875.00",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-11",
"vehiculo_principal": "F6P722",
"vehiculo_secundario": null,
"licencia_conductor": "Q09715404",
"nombre_conductor": "HUAMAN ÑAHUE, JUAN",
"documento_conductor": "09715404",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "BOLSA CEMENTO APU",
"cantidad": 550.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
},
{
"descripcion": "BOLSA CEMENTO ANDINO ULTRA HS",
"cantidad": 200.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/001_correo/20506689326-31-V002-11.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:biz="urn:bizlinks:names:specification:ubl:peru:schema:xsd:BizlinksAggregateComponents-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1"><ext:UBLExtensions><ext:UBLExtension><cbc:ID>EBIZ</cbc:ID><ext:ExtensionContent><biz:AdditionalInformation><biz:AdditionalProperty><cbc:ID>8025</cbc:ID><cbc:Value><![CDATA[278-0021 278-0026 278-0017]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8024</cbc:ID><cbc:Value><![CDATA[ventas@cemfisur.com.pe]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8023</cbc:ID><cbc:Value><![CDATA[www.grupodansa.com.pe]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9670</cbc:ID><cbc:Value><![CDATA[MATERIALES DE CONSTRUCCION]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9673</cbc:ID><cbc:Value><![CDATA[Nro.Cuota = 1 Fecha de Vencimiento = 2026-06-05 Tipo de Moneda = PEN Monto de Cuota = 938.79]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9611</cbc:ID><cbc:Value><![CDATA[OPERACION SUJETA A SPOT 027]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>8185</cbc:ID><cbc:Value><![CDATA[CREDITO A 08 DIAS]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9043</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9049</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9218</cbc:ID><cbc:Value><![CDATA[BUITRON BEDON LILA LAURA]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9130</cbc:ID><cbc:Value><![CDATA[---]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9099</cbc:ID><cbc:Value><![CDATA[28/05/2026]]></cbc:Value></biz:AdditionalProperty><biz:AdditionalProperty><cbc:ID>9925</cbc:ID><cbc:Value><![CDATA[20100029741]]></cbc:Value></biz:AdditionalProperty></biz:AdditionalInformation></ext:ExtensionContent></ext:UBLExtension><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="signatureKG"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>Jwf047g91naqJakpWbK9LIu1bvo=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>IQWmZ3xeIBe3U7UxB267Zpzr0fL4CC8NQtvcgpOiHxVw97vHXS+WA/ADBDUKxQLcmraEGxnozItQBHJ067Fsy1YXAP0AyByOlCJKQl/gxfVTKg+hyIIZbizVQlX8vahRydEalLioTP3aNyWUF5jkKrCatQYYLIyUtkzMB6DUzOEvYR5WcDiAd54Q1fu5Vxps9gZXtWgu+aWA7TXdO/vRg3qKsBAkgr4WNNEp5YXB1piY5aMl4v6/7qDtmGgz3dwaTMERH9aWOtdeP5ZiaWG81hDtdp9/z6er/28pBC4Bw5Sq85B3aiEu/nUfeEMzgJhBjPaGfjgC7KJLOS611r7sWA==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIGJTCCBQ2gAwIBAgITMwAAab3LtCvmGjz0EQAAAABpvTANBgkqhkiG9w0BAQsFADCBkjELMAkGA1UEBhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxJjAkBgNVBAsTHUNvcHlyaWdodCAoYykgMjAxNiBXSVNlS2V5IFNBMRYwFAYDVQQLEw1JbnRlcm5hdGlvbmFsMTEwLwYDVQQDEyhXSVNlS2V5IENlcnRpZnlJRCBBZHZhbmNlZCBTZXJ2aWNlcyBDQSA0MB4XDTIzMDQyNDE2MTEzNFoXDTI2MDQyMzE2MTEzNFowgesxCzAJBgNVBAYTAlBFMQ0wCwYDVQQHEwRMSU1BMRgwFgYDVQQKEw9CSVpMSU5LUyBTLkEuQy4xLzAtBgNVBAsMJlZhbGlkYXRlZCBieSBaeVRydXN0IFNvY2llZGFkIEFuw7NuaW1hMRQwEgYDVQQLEwsyMDQ3ODAwNTAxNzEVMBMGA1UECxMMRE5JIDA3ODYxNDU2MSwwKgYDVQQDEyNKQVZJRVIgRkVSTkFORE8gRFJBWEwgR0FSQ0lBIFJPU0VMTDEnMCUGCSqGSIb3DQEJARYYamF2aWVyLmRyYXhsQGJpemxpbmtzLmxhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv4O4GDlvAP4xTbCAjfQAFKjqPJX6wrJ439SvsYbEDHE1QBw191JJiUCA8diVqyJC3f9q5zPMR84+Gpscf1njSPOpxqBK+hU28mR/0lbV31fVKTOeZ0OWVepKTlbmdxf3c4GFVWQownyrD6rzdWC1fpFi3+g3ibkDLXbTVzMACwp5SG8AEmalY/aDEEReDhJjK9ljl3s/v2YnF2Rdv03A9zdRmQ3UT/xYfAAnWp7G7b0G2Fa5QirH7WVxcV0UKuef8ajl7hqdHJy7CBBIAX0OllbqET8rwnu19MzjN7hu1mwqEk6sQeRb3EJIs0GH1fLVS7s0O162s/fSAgwfJwyNVQIDAQABo4ICFzCCAhMwHQYDVR0OBBYEFJnLNJRPWOgtMEdxj/pfn/8eJ+6TMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBT05JtX0q7CnohNALorr2XJY6mHmzA8BgNVHR8ENTAzMDGgL6AthitodHRwOi8vcHVibGljLndpc2VrZXkuY29tL2NybC93Y2lkYXNjYTQuY3JsMG0GCCsGAQUFBwEBBGEwXzA3BggrBgEFBQcwAoYraHR0cDovL3B1YmxpYy53aXNla2V5LmNvbS9jcnQvd2NpZGFzY2E0LmNydDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3Aud2lzZWtleS5jb20vMCkGA1UdJQQiMCAGCCsGAQUFBwMCBgorBgEEAYI3CgMMBggrBgEFBQcDBDA1BgkrBgEEAYI3FQoEKDAmMAoGCCsGAQUFBwMCMAwGCisGAQQBgjcKAwwwCgYIKwYBBQUHAwQwIwYDVR0RBBwwGoELine truncated
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><DespatchAdvice xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2" xmlns:biz="urn:bizlinks:names:specification:ubl:peru:schema:xsd:BizlinksAggregateComponents-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1"><ext:UBLExtensions><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="signatureKG"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>LJbqWnNTP13375hfdWAHuk/S37w=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>i33WV09veYIM/veAa0S0LjO6PU5N3McU9BehPTKvSvjNEmUTWX/1dusHtoJBPeX3LKj6KS8VvelYnLVI2FlMVnOv08MEIYNxhuyMso9AtY29Wcbb1DKL8ZSaN0YK5l6kZEl02glxqSIKd3NA0TbT5PgivOczQuShaGu0f7MicogEhfe7Z3BA+/1orU7YQa/ZCComhQk+E2wx0NtzBqP9bDsxHSUJ0JmKXULOUE9ertAYjxY0ZW31q5LxJzTHoozDzWLzxBFtnHX7LOmbvSIuv50VQHRaBAz08ApY/UjunEiJraaSIXfBA3z3GIbFJh3ocRK5WdaDDtoIuz4PhdAcSQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIGJTCCBQ2gAwIBAgITMwAAab3LtCvmGjz0EQAAAABpvTANBgkqhkiG9w0BAQsFADCBkjELMAkGA1UEBhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxJjAkBgNVBAsTHUNvcHlyaWdodCAoYykgMjAxNiBXSVNlS2V5IFNBMRYwFAYDVQQLEw1JbnRlcm5hdGlvbmFsMTEwLwYDVQQDEyhXSVNlS2V5IENlcnRpZnlJRCBBZHZhbmNlZCBTZXJ2aWNlcyBDQSA0MB4XDTIzMDQyNDE2MTEzNFoXDTI2MDQyMzE2MTEzNFowgesxCzAJBgNVBAYTAlBFMQ0wCwYDVQQHEwRMSU1BMRgwFgYDVQQKEw9CSVpMSU5LUyBTLkEuQy4xLzAtBgNVBAsMJlZhbGlkYXRlZCBieSBaeVRydXN0IFNvY2llZGFkIEFuw7NuaW1hMRQwEgYDVQQLEwsyMDQ3ODAwNTAxNzEVMBMGA1UECxMMRE5JIDA3ODYxNDU2MSwwKgYDVQQDEyNKQVZJRVIgRkVSTkFORE8gRFJBWEwgR0FSQ0lBIFJPU0VMTDEnMCUGCSqGSIb3DQEJARYYamF2aWVyLmRyYXhsQGJpemxpbmtzLmxhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv4O4GDlvAP4xTbCAjfQAFKjqPJX6wrJ439SvsYbEDHE1QBw191JJiUCA8diVqyJC3f9q5zPMR84+Gpscf1njSPOpxqBK+hU28mR/0lbV31fVKTOeZ0OWVepKTlbmdxf3c4GFVWQownyrD6rzdWC1fpFi3+g3ibkDLXbTVzMACwp5SG8AEmalY/aDEEReDhJjK9ljl3s/v2YnF2Rdv03A9zdRmQ3UT/xYfAAnWp7G7b0G2Fa5QirH7WVxcV0UKuef8ajl7hqdHJy7CBBIAX0OllbqET8rwnu19MzjN7hu1mwqEk6sQeRb3EJIs0GH1fLVS7s0O162s/fSAgwfJwyNVQIDAQABo4ICFzCCAhMwHQYDVR0OBBYEFJnLNJRPWOgtMEdxj/pfn/8eJ+6TMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBT05JtX0q7CnohNALorr2XJY6mHmzA8BgNVHR8ENTAzMDGgL6AthitodHRwOi8vcHVibGljLndpc2VrZXkuY29tL2NybC93Y2lkYXNjYTQuY3JsMG0GCCsGAQUFBwEBBGEwXzA3BggrBgEFBQcwAoYraHR0cDovL3B1YmxpYy53aXNla2V5LmNvbS9jcnQvd2NpZGFzY2E0LmNydDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3Aud2lzZWtleS5jb20vMCkGA1UdJQQiMCAGCCsGAQUFBwMCBgorBgEEAYI3CgMMBggrBgEFBQcDBDA1BgkrBgEEAYI3FQoEKDAmMAoGCCsGAQUFBwMCMAwGCisGAQQBgjcKAwwwCgYIKwYBBQUHAwQwIwYDVR0RBBwwGoEYamF2aWVyLmRyYXhsQGJpemxpbmtzLmxhMEQGCSqGSIb3DQEJDwQ3MDUwDgYIKoZIhvcNAwICAgCAMA4GCCqGSIb3DQMEAgIAgDAHBgUrDgMCBzAKBggqhkiG9w0DBzBHBgNVHSAEQDA+MDwGCGCFdAUOCAEBMDAwLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cud2lzZWtleS5jb20vcmVwb3NpdG9yeS8wDQYJKoZIhvcNAQELBQADggEBAIwKxehlZoe1kqpoxrHL/uIeCxgMCidylRT19H45oBWonX6Y9KqvzvnkB6qp2Yv/2c0CiciRMtAFBQXi7u09uIJkB+WcPVgKezPKwWpc8AYz7j7IMsskl3g8XtqOZMbBbOaJcaGkZKYeJitYpQi6dPNNFA3ryAh/eVt2VJeRxDgSXpI/qD4Lij8A7vJonfO/ZBMYsuoHp2WK8Vf8bqsqeUhrHbCTPhlCkYf6TXiDNSDihiFHlZfEjGxERKgzopG9tb/+kJdiQSfpIKLG5ONfGoLROtTxWJrOqwcJDuYBphBuLaEgLUEv6UQ7ncH2qripCI7wQivJA3Sqj04xBz7lpWE=</ds:X509Certificate></ds:X509Data><ds:KeyValue><ds:RSAKeyValue><ds:Modulus>v4O4GDlvAP4xTbCAjfQAFKjqPJX6wrJ439SvsYbEDHE1QBw191JJiUCA8diVqyJC3f9q5zPMR84+Gpscf1njSPOpxqBK+hU28mR/0lbV31fVKTOeZ0OWVepKTlbmdxf3c4GFVWQownyrD6rzdWC1fpFi3+g3ibkDLXbTVzMACwp5SG8AEmalY/aDEEReDhJjK9ljl3s/v2YnF2Rdv03A9zdRmQ3UT/xYfAAnWp7G7b0G2Fa5QirH7WVxcV0UKuef8ajl7hqdHJy7CBBIAX0OllbqET8rwnu19MzjN7hu1mwqEk6sQeRb3EJIs0GH1fLVS7s0O162s/fSAgwfJwyNVQ==</ds:Modulus><ds:Exponent>AQAB</ds:Exponent></ds:RSAKeyValue></ds:KeyValue></ds:KeyInfo></ds:Signature></ext:ExtensionContent></ext:UBLExtension></ext:UBLExtensions><cbc:UBLVersionID>2.1</cbc:UBLVersionID><cbc:CustomizationID>2.0</cbc:CustomizationID><cbc:ID>V001-00000227</cbc:ID><cbc:IssueDate>2026-05-22</cbc:IssueDate><cbc:IssueTime>11:54:57</cbc:IssueTime><cbc:DespatchAdviceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">31</cbc:DespatchAdviceTypeCode><cbc:Note>ORR0023116 Placa Secundaria:D1K975</cbc:Note><cac:AdditionalDocumentReference><cbc:ID>T001-0129352</cbc:ID><cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gemLine truncated
@@ -0,0 +1,57 @@
[
{
"ruc_emisor": "20340843607",
"razon_social_emisor": "A. BERIO Y CIA S.A.C. RUC: 20100029741",
"fecha_emision": "28/05/2026",
"tipo": "01",
"serie": "F002",
"numero": "2188",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C. RUC: 20100029741",
"total_factura": "977.91",
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": "39.12",
"lineas": [
{
"descripcion": "(afecto al IGV) SERVI. DE TRANSP. 730 BLS. X 1.3396 Marca: - Párametro Legado: CARGA 31.025 TN VAL. REF S/977.91 MTC 151307699 Párametro Legado: GRR.T001-00129352 GRT. V001-227 CONF. VEH.T3S3 000000 ZZ 828.73 Párametro Legado: P.LLEGADA. AV SANTIAGO DE SURCO 4869 SURCO LIMA Párametro Legado: P.PARTI.AV. ATOCONGO 1860 VILLA MARIA DEL TRIUNFO Párametro Legado: PLACA :D7Y800-D1K975 Unidad de Medida: UND NOVECIENTOS SETENTA Y SIETE Y 91/100 SOLES Op. Gravada S/ 828.74 I.G.V S/ 149.17",
"cantidad": "1.00",
"precio_unitario": "0.00",
"total_linea": "828.73",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129352"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V001",
"numero": "227"
}
}
],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/002_correo/20340843607-01-F002-2188.pdf"
},
{
"ruc_emisor": "20340843607",
"razon_social_emisor": "CEMFISUR EIRL RUC: 20340843607",
"fecha_emision": "22/05/2026",
"tipo": "31",
"serie": "V001",
"numero": "227",
"ruc_receptor": "20100029741",
"razon_social_receptor": "CEMFISUR EIRL RUC: 20340843607",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/002_correo/20340843607-31-V001-227.pdf"
}
]
@@ -0,0 +1,100 @@
[
{
"ruc_emisor": "20340843607",
"razon_social_emisor": "CEMFISUR",
"fecha_emision": "2026-05-28",
"tipo": "01",
"serie": "F002",
"numero": "2188",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 977.91,
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": "027",
"porcentaje_detraccion": 4.0,
"monto_detraccion": 39.12,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERVI. DE TRANSP. 730 BLS. X 1.3396",
"cantidad": 1.0,
"precio_unitario": 977.91,
"total_linea": 828.73,
"notes_text": "P.PARTI.AV. ATOCONGO 1860 VILLA MARIA DEL TRIUNFO | P.LLEGADA. AV SANTIAGO DE SURCO 4869 SURCO LIMA | GRR.T001-00129352 GRT.V001-227 CONF. VEH.T3S3 | CARGA 31.025 TN VAL. REF S/977.91 MTC 151307699 | PLACA :D7Y800-D1K975 | UND | -",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129352"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V001",
"numero": "227"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/002_correo/20340843607-01-F002-2188.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20340843607",
"razon_social_emisor": "CEMFISUR E.I.R.L.",
"fecha_emision": "2026-05-22",
"tipo": "31",
"serie": "V001",
"numero": "227",
"ruc_receptor": "20340843607",
"razon_social_receptor": "CEMFISUR EIRL",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129352"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860",
"punto_llegada": "AV.SANTIAGO DE SURCO 4869 (EX-T.MARSANO) - SANTIAGO DE SURCO",
"peso_bruto": "31025.000",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-22",
"vehiculo_principal": "D7Y800",
"vehiculo_secundario": "D1K975",
"licencia_conductor": "D42857370",
"nombre_conductor": "YOMAR ERIKSON",
"documento_conductor": "42857370",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO SOL TIPO I - SOL",
"cantidad": 730.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/002_correo/20340843607-31-V001-227.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent><ds:Signature Id="signatureFACTURALOPERU">
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>Dkql0/yqrvnT3UZtGx52HnfykMY=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>fYa724o1T+XD0Pw0cLjRNEq98qPnEsQI+0q8+opTcHRS+J0WoxhZGMwBhBD2gMUj7LMe1yoQ8SY2X88mCMK9bRkfD+Rezj9KETRmgvM7HmdNyJnpRfRZnO9G9ZJwtVAInjZRXv1JKb26e8g0nY9ebUkLOgHBru8sQzKxNKqdoSX4lxLHc0ewgPsQKsce0jwwQRm0wpMN2JaYrpyydSg+HjXjswHfterYaYOeL3rO/7Xt1N/c6S+xqRgBmrRJqE46B402T7B5J/CTW1BZrSCSSy3WFCwMcJAj59FkH6f8sEPjShlJ5OzA81nmEudt/80nCpry2bs7QO7h4L9zSQALRw==</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFbDCCBFSgAwIBAgIIFUldkkH9yjEwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMjAyMTM1OTU0WhcNMjcwMjAyMTM1OTAwWjCCARMxMTAvBgNVBAkMKExULiAyMCBNWi4gQiBVUkIuIFJFU0lELiBIT05PUiBZIExFQUxUQUQxITAfBgkqhkiG9w0BCQEWEm1hbGlhZ2FAbWlnYWZpLmNvbTEsMCoGA1UEAwwjU09GVFdBUkUgREUgRkFDVFVSQUNJT04gRUxFQ1RST05JQ0ExLDAqBgNVBAsMI0ZBQ1RVUkEgRUxFQ1RST05JQ0EgUlVDIDIwNTA2Njg5MzI2MSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxIDAeBgNVBAoMF01BVEVSSUFMRVMgRC9DQVNBIFMuQS5DMQ0wCwYDVQQHDARMSU1BMQswCQYDVQQGEwJQRTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOej26BfjO7htv3e1lPaj6I4rzJanTTGMUkD2fkEt4PLMnb7AWrht9cDj0SvqofT8w56jrlqLjV2GpsVgxMTUe+c5Eir2IO+IL8pQva1TFRwpzWCseyb5by1igpCdNKTeJH34DkoRLogc4c2pqIKtWYsPX3QsjEy0rjW6+7sX/JNAQcgrzgJWw4WZwIIjK3PkF7s1O0VqLjTaZhHhbP9bPkMO8u+0RogjKZvBGfKY6ftEMjaPqTtB6k6zbZx7pPk+bRNMjSOncIFBehbaHYt63CeDlJHDNf5u+4jSWgp5I4nxHggfMYmeHOZGiOGYh6hClITOq+/Mg67g5FUl9416/UCAwEAAaOCAY0wggGJMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUXYhbretl+/5oouTZbz1XcqLvK5kwZwYIKwYBBQUHAQEEWzBZMDUGCCsGAQUFBzAChilodHRwOi8vY3J0LmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNlcjAgBggrBgEFBQcwAYYUaHR0cDovL29jc3AubGxhbWEucGUwHQYDVR0RBBYwFIESbWFsaWFnYUBtaWdhZmkuY29tMEYGA1UdIAQ/MD0wOwYNKwYBBAGDl3cAAQADATAqMCgGCCsGAQUFBwIBFhxodHRwczovL2xsYW1hLnBlL3JlcG9zaXRvcnkvMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsLmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNybDAdBgNVHQ4EFgQUTtWOjRJrY+hai0Np8dEDrqh3tIcwDgYDVR0PAQH/BAQDAgbAMA0GCSqGSIb3DQEBCwUAA4IBAQCIe0oUU2YJTZY0jULGUgE09eyyE+qh+V6MZLGPyeieuM+sS7U2uqoctQLS2VVIbx+pPoJ/ubmDS4zxMgxejcyJwiW0u9cn1bUS7XxQ4W5lSdRY5vi1MOTB2PXE3h1ESB6T6EJcY4P+azSNXgxOtnUzeXRxGSOR2RcoSiBocfJWKWjcOrVyJ4hksLKhjjm7hwi4zWD4chiJfaaHx2an9vpx8/gZKy4zz4+q1kDSoXL4FqY7DWGtyxWJVW/J2nbTj8PdV6YDwkboLPPwFhDtN0OE7VBqpXIItxqXLGPWupL7PSnhYDAB1uDxWbfkBx7WtIpKMxT7zvmwzaTTlvEOgqnH</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>FE07-1559</cbc:ID>
<cbc:IssueDate>2026-05-13</cbc:IssueDate>
<cbc:IssueTime>17:20:00</cbc:IssueTime>
<cbc:DueDate>2026-05-27</cbc:DueDate>
<cbc:InvoiceTypeCode listID="0101">01</cbc:InvoiceTypeCode>
<cbc:Note languageLocaleID="1000"><![CDATA[Mil cuatrocientos veinte con 65/100 ]]></cbc:Note>
<cbc:DocumentCurrencyCode>PEN</cbc:DocumentCurrencyCode>
<cac:DespatchDocumentReference>
<cbc:ID>V002-11</cbc:ID>
<cbc:DocumentTypeCode>31</cbc:DocumentTypeCode>
</cac:DespatchDocumentReference>
<cac:Signature>
<cbc:ID>signatureFACTURALOPERU</cbc:ID>
<cbc:Note>FACTURALO</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>#signatureFACTURALOPERU</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6">20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[{MATERIALES D`CASA S.A.C}]]></cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:ID>150115</cbc:ID>
<cbc:AddressTypeCode>0000</cbc:AddressTypeCode>
<cbc:CityName>LIMA</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>LA VICTORIA</cbc:District>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. Parinacochas 1060]]></cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode>PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Telephone>994 295 061</cbc:Telephone>
<cbc:ElectronicMail>materialesdcasa@gmail.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA S.A.C.]]></cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:ID>150143</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. 26 DE NOVIEMBRE NRO. 2473 A.H. VIRGEN DE LOURDES]]></cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode>PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ElectronicMail>recepcion@aberio.com.pe</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Credito</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">1420.65</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Cuota001</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">56.83</cbc:Amount>
<cbc:PaymentDueDate>2026-05-27</cbc:PaymentDueDate>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Cuota002</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">1363.82</cbc:Amount>
<cbc:PaymentDueDate>2026-05-27</cbc:PaymentDueDate>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">216.71</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">1203.94</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">216.71</cbc:TaxAmount>
<cac:TaxCategory>
<cac:TaxScheme>
<cbc:ID>1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="PEN">1203.94</cbc:LineExtensionAmount>
<cbc:TaxInclusiveAmount currencyID="PEN">1420.65</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="PEN">1420.65</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="ZZ">550</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="PEN">882.89</cbc:LineExtensionAmount>
<cac:PricingReference>
<cac:AlternativeConditionPrice>
<cbc:PriceAmount currencyID="PEN">1.894200</cbc:PriceAmount>
<cbc:PriceTypeCode>01</cbc:PriceTypeCode>
</cac:AlternativeConditionPrice>
</cac:PricingReference>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">158.92</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">882.89</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">158.92</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:Percent>18.00</cbc:Percent>
<cbc:TaxExemptionReasonCode>10</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID>1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description><![CDATA[SERVICIO TRANSPORTE CEMENTO APU 027 SUNAT]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>10000541</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode>51122201</cbc:ItemClassificationCode>
</cac:CommodityClassification>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="PEN">1.605254</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="ZZ">200</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="PEN">321.05</cbc:LineExtensionAmount>
<cac:PricingReference>
<cac:AlternativeConditionPrice>
<cbc:PriceAmount currencyID="PEN">1.894200</cbc:PriceAmount>
<cbc:PriceTypeCode>01</cbc:PriceTypeCode>
</cac:AlternativeConditionPrice>
</cac:PricingReference>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">57.79</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">321.05</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">57.79</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:Percent>18.00</cbc:Percent>
<cbc:TaxExemptionReasonCode>10</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID>1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description><![CDATA[SERVICIO TRANSPORTE CEMTO ANDINO/ULTRA 027 SUNAT]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>10000539</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode>51122199</cbc:ItemClassificationCode>
</cac:CommodityClassification>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="PEN">1.605254</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<DespatchAdvice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent><ds:Signature Id="signatureFACTURALOPERU">
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>jh2FnXCc6mcZFOLNh4yidKCBNOE=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>QkBKgCtC1pMy8xW4kF227KpiRiTSqwS9rmx4Ja1NHcnyh+2CYOLoeWRX1CsfGZe2Twayxe79XojFYvFiDsl7uM6PUySq7BB1OdBj6ZAR4TJYLUQXuBBXRFL2DI4j6Btuu/q8AIPHmrJepk/2tWJqb/oiBo2FhoGdEXCmkyZgHNAIIiRYQD7l00GhDVolirDrkii6zo/grWNFUGZC95SRFxgeQF23gED2PwakIzzMOqQrb/7IWh0er+ZF+Vk8QB8Y1CGHtRVytKMX+sT4K+G4O/tTT5GCQizs9HZjN5kxB1VMtM4GlUy4dpWuVblZKLREpZ9RFP2glThrohebgTGq1g==</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFbDCCBFSgAwIBAgIIFUldkkH9yjEwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMjAyMTM1OTU0WhcNMjcwMjAyMTM1OTAwWjCCARMxMTAvBgNVBAkMKExULiAyMCBNWi4gQiBVUkIuIFJFU0lELiBIT05PUiBZIExFQUxUQUQxITAfBgkqhkiG9w0BCQEWEm1hbGlhZ2FAbWlnYWZpLmNvbTEsMCoGA1UEAwwjU09GVFdBUkUgREUgRkFDVFVSQUNJT04gRUxFQ1RST05JQ0ExLDAqBgNVBAsMI0ZBQ1RVUkEgRUxFQ1RST05JQ0EgUlVDIDIwNTA2Njg5MzI2MSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxIDAeBgNVBAoMF01BVEVSSUFMRVMgRC9DQVNBIFMuQS5DMQ0wCwYDVQQHDARMSU1BMQswCQYDVQQGEwJQRTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOej26BfjO7htv3e1lPaj6I4rzJanTTGMUkD2fkEt4PLMnb7AWrht9cDj0SvqofT8w56jrlqLjV2GpsVgxMTUe+c5Eir2IO+IL8pQva1TFRwpzWCseyb5by1igpCdNKTeJH34DkoRLogc4c2pqIKtWYsPX3QsjEy0rjW6+7sX/JNAQcgrzgJWw4WZwIIjK3PkF7s1O0VqLjTaZhHhbP9bPkMO8u+0RogjKZvBGfKY6ftEMjaPqTtB6k6zbZx7pPk+bRNMjSOncIFBehbaHYt63CeDlJHDNf5u+4jSWgp5I4nxHggfMYmeHOZGiOGYh6hClITOq+/Mg67g5FUl9416/UCAwEAAaOCAY0wggGJMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUXYhbretl+/5oouTZbz1XcqLvK5kwZwYIKwYBBQUHAQEEWzBZMDUGCCsGAQUFBzAChilodHRwOi8vY3J0LmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNlcjAgBggrBgEFBQcwAYYUaHR0cDovL29jc3AubGxhbWEucGUwHQYDVR0RBBYwFIESbWFsaWFnYUBtaWdhZmkuY29tMEYGA1UdIAQ/MD0wOwYNKwYBBAGDl3cAAQADATAqMCgGCCsGAQUFBwIBFhxodHRwczovL2xsYW1hLnBlL3JlcG9zaXRvcnkvMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsLmxsYW1hLnBlL2xsYW1hcGVzdGFuZGFyZGNhLmNybDAdBgNVHQ4EFgQUTtWOjRJrY+hai0Np8dEDrqh3tIcwDgYDVR0PAQH/BAQDAgbAMA0GCSqGSIb3DQEBCwUAA4IBAQCIe0oUU2YJTZY0jULGUgE09eyyE+qh+V6MZLGPyeieuM+sS7U2uqoctQLS2VVIbx+pPoJ/ubmDS4zxMgxejcyJwiW0u9cn1bUS7XxQ4W5lSdRY5vi1MOTB2PXE3h1ESB6T6EJcY4P+azSNXgxOtnUzeXRxGSOR2RcoSiBocfJWKWjcOrVyJ4hksLKhjjm7hwi4zWD4chiJfaaHx2an9vpx8/gZKy4zz4+q1kDSoXL4FqY7DWGtyxWJVW/J2nbTj8PdV6YDwkboLPPwFhDtN0OE7VBqpXIItxqXLGPWupL7PSnhYDAB1uDxWbfkBx7WtIpKMxT7zvmwzaTTlvEOgqnH</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>V002-11</cbc:ID>
<cbc:IssueDate>2026-05-11</cbc:IssueDate>
<cbc:IssueTime>13:05:50</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT">31</cbc:DespatchAdviceTypeCode>
<!-- DOCUMENTOS ADICIONALES (Catalogo D41) -->
<cac:AdditionalDocumentReference>
<cbc:ID>T001-128662</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo61">09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de remisión remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>T001-128661</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo61">09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de remisión remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:Signature>
<cbc:ID>signatureFACTURALOPERU</cbc:ID>
<cbc:Note>FACTURALO</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>#signatureFACTURALOPERU</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<!-- DATOS DEL EMISOR (TRANSPORTISTA) -->
<cac:DespatchSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<!-- DATOS DEL RECEPTOR (DESTINATARIO) -->
<cac:DeliveryCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20506689326</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[MATERIALES D`CASA S.A.C]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<!-- DATOS DE QUIEN PAGA EL SERVICIO -->
<!-- DATOS DEL TRASLADO -->
<cac:Shipment>
<!-- ID OBLIGATORIO POR UBL -->
<cbc:ID>SUNAT_Envio</cbc:ID>
<!-- PESO BRUTO TOTAL DE LA CARGA-->
<cbc:GrossWeightMeasure unitCode="KGM">31875.00</cbc:GrossWeightMeasure>
<!-- INDICADORES -->
<cac:ShipmentStage>
<!-- FECHA DE INICIO DEL TRASLADO -->
<cac:TransitPeriod>
<cbc:StartDate>2026-05-11</cbc:StartDate>
</cac:TransitPeriod>
<!-- CONDUCTOR PRINCIPAL -->
<cac:DriverPerson>
<!-- TIPO Y NUMERO DE DOCUMENTO DE IDENTIDAD -->
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="1">09715404</cbc:ID>
<!-- NOMBRES -->
<cbc:FirstName>HUAMAN ÑAHUE, JUAN</cbc:FirstName>
<!-- APELLIDOS -->
<cbc:FamilyName>DE DIOS</cbc:FamilyName>
<!-- TIPO DE CONDUCTOR: PRINCIPAL -->
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<!-- LICENCIA DE CONDUCIR -->
<cbc:ID>Q09715404</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
<!-- CONDUCTOR SECUNDARIO -->
</cac:ShipmentStage>
<cac:Delivery>
<!-- DIRECCION DEL PUNTO DE LLEGADA -->
<cac:DeliveryAddress>
<!-- UBIGEO DE LLEGADA -->
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150502</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[CA. PANAMERICANA SUR KM 98 SANTA CRUZ]]></cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<!-- DIRECCION DEL PUNTO DE PARTIDA -->
<cac:DespatchAddress>
<!-- UBIGEO DE PARTIDA -->
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<!-- DIRECCION COMPLETA Y DETALLADA DE PARTIDA -->
<cac:AddressLine>
<cbc:Line><![CDATA[AV. ATOCONGO 1860]]></cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<!-- DATOS DEL REMITENTE -->
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" schemeAgencyName="PE:SUNAT" schemeName="Documento de Identidad" schemeID="6">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA S.A.C.]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<!-- NUMERO DE CONTENEDOR -->
<cbc:ID>-</cbc:ID>
<cac:TransportEquipment>
<!-- VEHICULO PRINCIPAL -->
<!-- PLACA - VEHICULO PRINCIPAL -->
<cbc:ID>F6P722</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>15M24026972E</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
<!-- VEHICULOS SECUNDARIOS -->
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<!-- DETALLES DE BIENES A TRASLADAR -->
<cac:DespatchLine>
<!-- NUMERO DE ORDEN DEL ITEM -->
<cbc:ID>1</cbc:ID>
<!-- CANTIDAD -->
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">550.0000</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description><![CDATA[BOLSA CEMENTO APU]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID><![CDATA[10000540]]></cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
<cac:DespatchLine>
<!-- NUMERO DE ORDEN DEL ITEM -->
<cbc:ID>2</cbc:ID>
<!-- CANTIDAD -->
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">200.0000</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description><![CDATA[BOLSA CEMENTO ANDINO ULTRA HS]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID><![CDATA[10003615]]></cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
</DespatchAdvice>
@@ -0,0 +1,55 @@
[
{
"ruc_emisor": "20506689326",
"razon_social_emisor": "A. BERIO Y CIA S.A.C.",
"fecha_emision": "27/05/2026",
"tipo": "01",
"serie": "FE07",
"numero": "1559",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": "1363.82",
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": null,
"porcentaje_detraccion": "4",
"monto_detraccion": "56.83",
"lineas": [
{
"descripcion": "550 ZZ SERVICIO TRANSPORTE CEMENTO APU 027 SUNAT 0",
"cantidad": "1.89",
"precio_unitario": "1.89",
"total_linea": "1041.81",
"guias_remision": [],
"guia_transportista": null
},
{
"descripcion": "200 ZZ SERVICIO TRANSPORTE CEMTO ANDINO/ULTRA 027 SUNAT 0",
"cantidad": "1.89",
"precio_unitario": "1.89",
"total_linea": "378.84",
"guias_remision": [],
"guia_transportista": null
}
],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/003_correo/20506689326-01-FE07-1559.pdf"
},
{
"ruc_emisor": "20506689326",
"razon_social_emisor": "MATERIALES D`CASA S.A.C",
"fecha_emision": null,
"tipo": "31",
"serie": "V002",
"numero": "11",
"ruc_receptor": "20100029741",
"razon_social_receptor": "MATERIALES D`CASA S.A.C",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/003_correo/20506689326-31-V002-11.pdf"
}
]
@@ -0,0 +1,139 @@
[
{
"ruc_emisor": "20506689326",
"razon_social_emisor": "MATERIALES D`CASA S.A.C",
"fecha_emision": "2026-05-13",
"tipo": "01",
"serie": "FE07",
"numero": "1559",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 1420.65,
"documentos_referencia": [
{
"tipo": "31",
"serie": "V002",
"numero": "11"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERVICIO TRANSPORTE CEMENTO APU 027 SUNAT",
"cantidad": 550.0,
"precio_unitario": 1.8942,
"total_linea": 882.89,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "128662"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V002",
"numero": "11"
}
},
{
"descripcion": "SERVICIO TRANSPORTE CEMTO ANDINO/ULTRA 027 SUNAT",
"cantidad": 200.0,
"precio_unitario": 1.8942,
"total_linea": 321.05,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "128661"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V002",
"numero": "11"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/003_correo/20506689326-01-FE07-1559.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20506689326",
"razon_social_emisor": "MATERIALES D`CASA S.A.C",
"fecha_emision": "2026-05-11",
"tipo": "31",
"serie": "V002",
"numero": "11",
"ruc_receptor": "20506689326",
"razon_social_receptor": "MATERIALES D`CASA S.A.C",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "128662"
},
{
"tipo": "09",
"serie": "T001",
"numero": "128661"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860",
"punto_llegada": "CA. PANAMERICANA SUR KM 98 SANTA CRUZ",
"peso_bruto": "31875.00",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-11",
"vehiculo_principal": "F6P722",
"vehiculo_secundario": null,
"licencia_conductor": "Q09715404",
"nombre_conductor": "HUAMAN ÑAHUE, JUAN",
"documento_conductor": "09715404",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "BOLSA CEMENTO APU",
"cantidad": 550.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
},
{
"descripcion": "BOLSA CEMENTO ANDINO ULTRA HS",
"cantidad": 200.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/003_correo/20506689326-31-V002-11.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ext:UBLExtensions><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="SignatureSP"><SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>8LhsuraTHRFahN/X2RKshF/bPLM=</DigestValue></Reference></SignedInfo><SignatureValue xmlns="http://www.w3.org/2000/09/xmldsig#">LVrsrAh9EwLNKiczJjoHiLSjH9JVa47YaKsflcQOlaxmGtmV3jMBVS4IKUVAurgypeSIOdHFQRhPA2FzT0nAB0ROUhqnrAlRKX0nY5/lFb0WZOjSe7IKbV8bDwYhjdYC/oo03/3sgNudJUYW6zErMyooyY0UX4GV4dZfqNxEqdeQvZiz+2QdLGPWUb+Ibq/fGHseignAE8O2cEHmDDvqk6ABGtEkPc9JPpYep14KEzws9LeheVaOAzPsk/Oe4tDM+XmYMk+jE1riHlTDZDPEVUr1Liw5uU1kVdkij3L9+fdmkP5BwbkHgEJFljvRepuI8N66b8xKwqFmyYlBqHdehA==</SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509SubjectName>C=PE, L=LIMA, O=R P TRANSFER S.A.C., OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20551006914, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=contabilidad@difenas.com.pe, STREET=AV. LIMA NRO. 2210 P.J. JOSE GALVEZ</X509SubjectName><X509Certificate>MIIFdjCCBF6gAwIBAgIISJfl+vjrcZUwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDI0MTY1NTM2WhcNMjYxMDI0MTY1NTAwWjCCARQxLDAqBgNVBAkMI0FWLiBMSU1BIE5STy4gMjIxMCBQLkouIEpPU0UgR0FMVkVaMSowKAYJKoZIhvcNAQkBFhtjb250YWJpbGlkYWRAZGlmZW5hcy5jb20ucGUxLDAqBgNVBAMMI1NPRlRXQVJFIERFIEZBQ1RVUkFDSU9OIEVMRUNUUk9OSUNBMSwwKgYDVQQLDCNGQUNUVVJBIEVMRUNUUk9OSUNBIFJVQyAyMDU1MTAwNjkxNDEhMB8GA1UECwwYVmFsaWRhZG8gcG9yIExsYW1hLnBlIEVSMR0wGwYDVQQKDBRSICBQIFRSQU5TRkVSIFMuQS5DLjENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC67JWukAAwxXhKV/LTmrjHALY44d1pU9Y3JJE6TSDgjtimIs1h7eZot/OC9d+OGUhy383TMSxqdIM4W77PMDRH5Iefh6H5EKaPI2YsCIc3lT3N15JdK+EQNyPeAh/mC4OWrGuVXDgU3MPD7EbtgjvYG8wLEJUIJBxw9bil4SiTLHPiKpRWySrtWPZeBTYfGdR/7xoRxSRSCOwgi04lp8lEv8Wwfyb2rsrltRrx44RGZFLy0GRjLw0ktzx2OL3VYGK51x0YD91wIKiFUN3co3vgLwyiuLiEr09QXpFm9zUjupxT9V3RtmyWOv2x6cFAWswLGYNVl7JnspVGae8GV23nAgMBAAGjggGWMIIBkjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCYGA1UdEQQfMB2BG2NvbnRhYmlsaWRhZEBkaWZlbmFzLmNvbS5wZTBGBgNVHSAEPzA9MDsGDSsGAQQBg5d3AAEAAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly9sbGFtYS5wZS9yZXBvc2l0b3J5LzAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jcmwwHQYDVR0OBBYEFAWB+XQmngVqzCM13NVubfDKC12wMA4GA1UdDwEB/wQEAwIGwDANBgkqhkiG9w0BAQsFAAOCAQEAu4Ajzm//yKf4bONcHdDISUaC3P8rh5CW0NSkVS/MsrXBGB3SCJXk2LR7wLVrUco3/DP3wMkel4XVDL6ftTfim+XmLXeAnuoFYTBGVJtuZMvuZAsncGIu3iFOE1K1v8yhzUykh+L7gLgPz60o8zHOXN48G3mHeNtaz3gSPGFSy0q6abiSN1n1A0VH8jj0PNf5Q0kjwdwfv0IhUAImgvLcPYeWXPobN2Ad4EqTkgvwOlLg1J7Tv6GJybjGKFSAHWi1Bbu+hegTS8SgGPDmXTsTe0ZPZZ+YzvupS/zzDEYkC1OV7b/56t4sFn5KVrvpZqsoisqCzMV4NOc0XgbTsMVoJg==</X509Certificate></X509Data></KeyInfo></ds:Signature></ext:ExtensionContent></ext:UBLExtension></ext:UBLExtensions><cbc:UBLVersionID>2.1</cbc:UBLVersionID><cbc:CustomizationID schemeAgencyName="PE:SUNAT">2.0</cbc:CustomizationID><cbc:ID>F004-0005989</cbc:ID><cbc:IssueDate>2026-05-29</cbc:IssueDate><cbc:InvoiceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT" listID="0101">01</cbc:InvoiceTypeCode><cbc:Note languageLocaleID="1000">DOSCIENTOS VEINTISEIS CON 45/100 SOLES</cbc:Note><cbc:DocumentCurrencyCode listName="Currency" listAgencyName="United Nations Economic Commission for Europe" listID="ISO 4217 Alpha">PEN</cbc:DocumentCurrencyCode><cbc:LineCountNumeric>1</cbc:LineCountNumeric><cac:Signature><cbc:ID>signatureCF</cbc:ID><cac:SignatoryParty><cac:PartyIdentification><cbc:ID>20551006914</cbc:ID></cac:PartyIdentification><cac:PartyName><cbc:Name><![CDATA[R & P TRANSFLine truncated
G.R.T. : V002-0001427
P. PARTIDA : AV. ATOCONGO 1860 VMT LIMA LIMA
P. LLEGADA : CAR. PANAMERICANA SUR KM 59 MZA. A LOTE. 1 A.V LA PONDEROSA- LIMA- LIMA -PUCUSANA
CONF. VEHIC : T3S3
VALOR REFER :S/ 226.45
T.N. : 5.312]]></cbc:Description><cac:SellersItemIdentification><cbc:ID><![CDATA[SERV-CEM-SOL-125]]></cbc:ID></cac:SellersItemIdentification></cac:Item><cac:Price><cbc:PriceAmount currencyID="PEN">1.5352540000</cbc:PriceAmount></cac:Price></cac:InvoiceLine></Invoice>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><DespatchAdvice xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2"><ext:UBLExtensions><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="SignatureSP"><SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>NZR3F6AHQuWc6dokcbp/vG99cYQ=</DigestValue></Reference></SignedInfo><SignatureValue xmlns="http://www.w3.org/2000/09/xmldsig#">hkguA93vzLnZCwlhqrSmtqFvMrQlTH17PyOixuXvJFFszq5/90MLblUlBVOOQQJj1MJNdGifBpKB/GJzLFPk5SIY7jYlkmIAuusrcvu2RbKllcCmBENxdXD/rNQ4Zl5N1pPi/Ob8jLDANLNz573c+HZnLP5O0LUq/kbcbAEe59FJjOG36+iPwPu/hDloKlLC+B++HxTWGd8yXlbUeaEV5lLe8Cd4gzmisyUd/HTlgKMf7/rUmL0es8kbSLOm8o2hX2DRwcuern7I44T47eq1TPKh8ePrNMCfKdFZrMEF3fbdwwZBM45MUq8+E11g//rIzjNsnUEd5mSQSKcD59StQA==</SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509SubjectName>C=PE, L=LIMA, O=R P TRANSFER S.A.C., OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20551006914, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=contabilidad@difenas.com.pe, STREET=AV. LIMA NRO. 2210 P.J. JOSE GALVEZ</X509SubjectName><X509Certificate>MIIFdjCCBF6gAwIBAgIISJfl+vjrcZUwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDI0MTY1NTM2WhcNMjYxMDI0MTY1NTAwWjCCARQxLDAqBgNVBAkMI0FWLiBMSU1BIE5STy4gMjIxMCBQLkouIEpPU0UgR0FMVkVaMSowKAYJKoZIhvcNAQkBFhtjb250YWJpbGlkYWRAZGlmZW5hcy5jb20ucGUxLDAqBgNVBAMMI1NPRlRXQVJFIERFIEZBQ1RVUkFDSU9OIEVMRUNUUk9OSUNBMSwwKgYDVQQLDCNGQUNUVVJBIEVMRUNUUk9OSUNBIFJVQyAyMDU1MTAwNjkxNDEhMB8GA1UECwwYVmFsaWRhZG8gcG9yIExsYW1hLnBlIEVSMR0wGwYDVQQKDBRSICBQIFRSQU5TRkVSIFMuQS5DLjENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC67JWukAAwxXhKV/LTmrjHALY44d1pU9Y3JJE6TSDgjtimIs1h7eZot/OC9d+OGUhy383TMSxqdIM4W77PMDRH5Iefh6H5EKaPI2YsCIc3lT3N15JdK+EQNyPeAh/mC4OWrGuVXDgU3MPD7EbtgjvYG8wLEJUIJBxw9bil4SiTLHPiKpRWySrtWPZeBTYfGdR/7xoRxSRSCOwgi04lp8lEv8Wwfyb2rsrltRrx44RGZFLy0GRjLw0ktzx2OL3VYGK51x0YD91wIKiFUN3co3vgLwyiuLiEr09QXpFm9zUjupxT9V3RtmyWOv2x6cFAWswLGYNVl7JnspVGae8GV23nAgMBAAGjggGWMIIBkjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCYGA1UdEQQfMB2BG2NvbnRhYmlsaWRhZEBkaWZlbmFzLmNvbS5wZTBGBgNVHSAEPzA9MDsGDSsGAQQBg5d3AAEAAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly9sbGFtYS5wZS9yZXBvc2l0b3J5LzAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jcmwwHQYDVR0OBBYEFAWB+XQmngVqzCM13NVubfDKC12wMA4GA1UdDwEB/wQEAwIGwDANBgkqhkiG9w0BAQsFAAOCAQEAu4Ajzm//yKf4bONcHdDISUaC3P8rh5CW0NSkVS/MsrXBGB3SCJXk2LR7wLVrUco3/DP3wMkel4XVDL6ftTfim+XmLXeAnuoFYTBGVJtuZMvuZAsncGIu3iFOE1K1v8yhzUykh+L7gLgPz60o8zHOXN48G3mHeNtaz3gSPGFSy0q6abiSN1n1A0VH8jj0PNf5Q0kjwdwfv0IhUAImgvLcPYeWXPobN2Ad4EqTkgvwOlLg1J7Tv6GJybjGKFSAHWi1Bbu+hegTS8SgGPDmXTsTe0ZPZZ+YzvupS/zzDEYkC1OV7b/56t4sFn5KVrvpZqsoisqCzMV4NOc0XgbTsMVoJg==</X509Certificate></X509Data></KeyInfo></ds:Signature></ext:ExtensionContent></ext:UBLExtension></ext:UBLExtensions><cbc:UBLVersionID>2.1</cbc:UBLVersionID><cbc:CustomizationID>2.0</cbc:CustomizationID><cbc:ID>V002-0001427</cbc:ID><cbc:IssueDate>2026-05-29</cbc:IssueDate><cbc:IssueTime>17:25:19</cbc:IssueTime><cbc:DespatchAdviceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT">31</cbc:DespatchAdviceTypeCode><cac:Signature><cbc:ID>V002-0001427</cbc:ID><cac:SignatoryParty><cac:PartyIdentification><cbc:ID>20551006914</cbc:ID></cac:PartyIdentification><cac:PartyName><cbc:Name><![CDATA[R & P TRANSFER S.A.C.]]></cbc:Name></cac:PartyName></cac:SignatoryParty><cac:DigitalSignatureAttachment><cac:ExternalReference><cbc:URI>#SignatureSP</cbc:URI></cac:ExternalReference></cac:DigitalSignatureAttachment></cac:Signature><cac:DespatchSupplierParty><cac:Party><cac:PartyIdentification><cbc:ID schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06" sLine truncated
@@ -0,0 +1,38 @@
[
{
"ruc_emisor": "20551006914",
"razon_social_emisor": "A. BERIO Y CIA S.A.C.",
"fecha_emision": "29/05/2026",
"tipo": "09",
"serie": "F004",
"numero": "5989",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/004_correo/20551006914-09-F004-5989.pdf"
},
{
"ruc_emisor": "20551006914",
"razon_social_emisor": null,
"fecha_emision": null,
"tipo": "31",
"serie": "V002",
"numero": "1427",
"ruc_receptor": "20100029741",
"razon_social_receptor": null,
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/004_correo/20551006914-31-V002-1427.pdf"
}
]
@@ -0,0 +1,105 @@
[
{
"ruc_emisor": "20551006914",
"razon_social_emisor": "R & P TRANSFER S.A.C.",
"fecha_emision": "2026-05-29",
"tipo": "01",
"serie": "F004",
"numero": "5989",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 226.45,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129723"
},
{
"tipo": "31",
"serie": "V002",
"numero": "1427"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "POR EL SERVICIO DE TRANSPORTE DE 125 BOLSAS DE CEMENTO SOL G.R.R.\t\t: T001-00129723\nG.R.T.\t\t: V002-0001427\nP. PARTIDA \t: AV. ATOCONGO 1860 VMT LIMA LIMA\nP. LLEGADA \t: CAR. PANAMERICANA SUR KM 59 MZA. A LOTE. 1 A.V LA PONDEROSA- LIMA- LIMA -PUCUSANA\nCONF. VEHIC\t: T3S3\nVALOR REFER\t:S/ 226.45\nT.N.\t\t: 5.312",
"cantidad": 125.0,
"precio_unitario": 1.8116,
"total_linea": 191.91,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129723"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V002",
"numero": "1427"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/004_correo/20551006914-01-F004-5989.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20551006914",
"razon_social_emisor": "R & P TRANSFER S.A.C.",
"fecha_emision": "2026-05-29",
"tipo": "31",
"serie": "V002",
"numero": "1427",
"ruc_receptor": "20550744024",
"razon_social_receptor": "CONSORCIO UNION FERRETERA S.A.C. - CONSUFER S.A.C.",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV ATOCONGO 1860 LIMA LIMA VMT",
"punto_llegada": "CAR.PANAMERICANA SUR KM 59 MZA. A LOTE. 1 A.V. LA PONDEROSA (PUENTE PU",
"peso_bruto": "5.313",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-29",
"vehiculo_principal": "C6Y857",
"vehiculo_secundario": "D2P996",
"licencia_conductor": "Q45322098",
"nombre_conductor": "LUIS MIGUEL",
"documento_conductor": "45322098",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO SOL T1",
"cantidad": 125.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/004_correo/20551006914-31-V002-1427.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ext:UBLExtensions><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="SignatureSP"><SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>oydc1/p9NAh68kMvAOT9oAUeGCU=</DigestValue></Reference></SignedInfo><SignatureValue xmlns="http://www.w3.org/2000/09/xmldsig#">zzCw8dLaTJ/r0BiNVSupKYiIujELgg8U2TWXw9mB52BDlKynFAGRalVcHccMit9teSKQMGYKdiLgzVaqhIWwQAxDIwUaaoudE2hSG2eHQdwCKqP0z76CjDiTZEgHj30agvxUjZxXeQbczvc5vKopUaRdLwDLEjFZyr0TftGQBujxuaAzQhU+X5D6yNdhxOXA0GPu00g92EfapGmbh5Wt1kUUFhrI1om+hOkbhXZATCHfQJ7eB5U0cEbfZyLqZw9Zvq6rnq5opWi35SUe3tkZjlN6oE4XvGjuXksD4c9plp/bQvGhZQlxg1D8UHnRerffLdZv7B3ZA1NtruNYJ6kvZw==</SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509SubjectName>C=PE, L=LIMA, O=DISTRIBUIDORA FERRETERA NASCA S.A.C. - DIFENAS S.A.C., OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20555929006, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=contabilidad@difenas.com.pe, STREET=AV. LIMA NRO. 2236 P.J. JOSE GALVEZ</X509SubjectName><X509Certificate>MIIFlzCCBH+gAwIBAgIIMW4CA4T7sdQwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDE0MTkxNDEwWhcNMjYxMDE0MTkxNDAwWjCCATUxLDAqBgNVBAkMI0FWLiBMSU1BIE5STy4gMjIzNiBQLkouIEpPU0UgR0FMVkVaMSowKAYJKoZIhvcNAQkBFhtjb250YWJpbGlkYWRAZGlmZW5hcy5jb20ucGUxLDAqBgNVBAMMI1NPRlRXQVJFIERFIEZBQ1RVUkFDSU9OIEVMRUNUUk9OSUNBMSwwKgYDVQQLDCNGQUNUVVJBIEVMRUNUUk9OSUNBIFJVQyAyMDU1NTkyOTAwNjEhMB8GA1UECwwYVmFsaWRhZG8gcG9yIExsYW1hLnBlIEVSMT4wPAYDVQQKDDVESVNUUklCVUlET1JBIEZFUlJFVEVSQSBOQVNDQSBTLkEuQy4gLSBESUZFTkFTIFMuQS5DLjENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDrXOQp5XJfVSlT7G/xjPQp25/XlHcTMqzFneRLQB7RBMEoVZMGS08TJOwb7A08F80MemcdHINfKu32W0vH+IkXftM80JzMMwcooM0HPx42rYm0kmYovhOdoDDOGXWEbBInQ7aTIQkW4/UkytdIVP3uk5sRoPVollMeu1FnvBBe0xU2xMCSTpd4gOQXGHittmYhgK2YCNdrKBRn/k/wULS9RsejCPV4FkUAeiZK2JSFphR4YZPddo3XLb/mUXwgoikAovQ4RyN2QqW+PRXI7GEuY9UN/DuEaDsdBNyKEddeYJnMRepZmDQEQr5RMbn4B5ObFaODKmkMe4lg/GE9sb11AgMBAAGjggGWMIIBkjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCYGA1UdEQQfMB2BG2NvbnRhYmlsaWRhZEBkaWZlbmFzLmNvbS5wZTBGBgNVHSAEPzA9MDsGDSsGAQQBg5d3AAEAAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly9sbGFtYS5wZS9yZXBvc2l0b3J5LzAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jcmwwHQYDVR0OBBYEFOekfrMbj8D7XHBMrINSvd5DMeY9MA4GA1UdDwEB/wQEAwIGwDANBgkqhkiG9w0BAQsFAAOCAQEAfeOGCQjp79Wh+8REraPlhIb7EOjJsbVR7W9eYNnIo2jDZMpBY8yxrQtcukGBjABn63L+N335eviU1hX6saLTXwcCrNrsYNYt4hQ7FQWaHsSp9AckMdRCxDvhIpQVheVKLLT/aSOV1loEa6J8A25wk5n+oS1nEex6x+7k210FcLOREyVbPxqrlCJPX8SCVcJOl84KOHlbrGmPDp2r30IycyM3mx7cJfIwNCbcNOXi46QqI6QINhooysZy4lSj7LqIDItzG3YaXlnauWSGPEsl8ggqcCOp5QdXSAeov+0tkTD9/83OA6/5Qv5dvp0x3Qq5JY4H/QBrh16SHQcHqxIMPw==</X509Certificate></X509Data></KeyInfo></ds:Signature></ext:ExtensionContent></ext:UBLExtension></ext:UBLExtensions><cbc:UBLVersionID>2.1</cbc:UBLVersionID><cbc:CustomizationID schemeAgencyName="PE:SUNAT">2.0</cbc:CustomizationID><cbc:ID>F004-0003544</cbc:ID><cbc:IssueDate>2026-05-29</cbc:IssueDate><cbc:InvoiceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT" listID="1001">01</cbc:InvoiceTypeCode><cbc:Note languageLocaleID="1000">SEISCIENTOS CUARENTA Y CUATRO CON 35/100 SOLES</cbc:Note><cbc:Note languageLocaleID="2006">Operación sujeta a detracción</cbc:Note><cbc:DocumentCurrencyCode listName="Currency" listAgencyName="United Nations Economic Commission for Europe" listID="ISO 4217 Alpha">PEN</cbc:DocumentCurrencyCode><cbc:LineCountNumeric>1</cbc:LineCountNumeric><cac:Signature><cbc:ID>signLine truncated
G.R.T. : V002-0001358
P. PARTIDA : AV. ATOCONGO 1860 VMT LIMA LIMA
P. LLEGADA : AV. PROLONGACION SAENZ PEÑA-LAS PALMAS MZ H LT.7 OTR.UCV 12203 SUBL B SECTOR CODIGO CATASTRAL
CONF. VEHIC : T3S3
VALOR REFER :S/ 644.35
T]]></cbc:Description><cac:SellersItemIdentification><cbc:ID><![CDATA[SERV-CEM-SOL-481]]></cbc:ID></cac:SellersItemIdentification></cac:Item><cac:Price><cbc:PriceAmount currencyID="PEN">1.1352540000</cbc:PriceAmount></cac:Price></cac:InvoiceLine></Invoice>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><DespatchAdvice xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2"><ext:UBLExtensions><ext:UBLExtension><ext:ExtensionContent><ds:Signature Id="SignatureSP"><SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>60qTHSXvO/FXkSbic7xmmBjlokk=</DigestValue></Reference></SignedInfo><SignatureValue xmlns="http://www.w3.org/2000/09/xmldsig#">sxQSd3SmESNNOy9vPdeOmSjpgV/EVhLJ/+opTLhshivwvIbU/XRaJ/GSxWB+yFTUu2tFzOJ+bQLxqmOMXl/R6xllxFshQ4tNh1Q73sCt0N1f9WE2LBm+g5Fk/v5sHtJIlJgcnpQiu8EYS0bCtyC2oiaNsLsY22jJvaSB/lISthRji7WAgWPbytQUtTYxZMYwLleh80RvZy05xmDrdjajiDMmSIBQeT89b3gYGv3VdaNNRERc+Yw/t7NxBcsGUfeO73VJo/C6UzDQNyiy9N3gwmjAYs1j2Mn41cEVlGQWPo9+FGewIpKkRdliNWeLXbAyrArV/y1Sy6kMECal1V5mHA==</SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509SubjectName>C=PE, L=LIMA, O=DISTRIBUIDORA FERRETERA NASCA S.A.C. - DIFENAS S.A.C., OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20555929006, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=contabilidad@difenas.com.pe, STREET=AV. LIMA NRO. 2236 P.J. JOSE GALVEZ</X509SubjectName><X509Certificate>MIIFlzCCBH+gAwIBAgIIMW4CA4T7sdQwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDE0MTkxNDEwWhcNMjYxMDE0MTkxNDAwWjCCATUxLDAqBgNVBAkMI0FWLiBMSU1BIE5STy4gMjIzNiBQLkouIEpPU0UgR0FMVkVaMSowKAYJKoZIhvcNAQkBFhtjb250YWJpbGlkYWRAZGlmZW5hcy5jb20ucGUxLDAqBgNVBAMMI1NPRlRXQVJFIERFIEZBQ1RVUkFDSU9OIEVMRUNUUk9OSUNBMSwwKgYDVQQLDCNGQUNUVVJBIEVMRUNUUk9OSUNBIFJVQyAyMDU1NTkyOTAwNjEhMB8GA1UECwwYVmFsaWRhZG8gcG9yIExsYW1hLnBlIEVSMT4wPAYDVQQKDDVESVNUUklCVUlET1JBIEZFUlJFVEVSQSBOQVNDQSBTLkEuQy4gLSBESUZFTkFTIFMuQS5DLjENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDrXOQp5XJfVSlT7G/xjPQp25/XlHcTMqzFneRLQB7RBMEoVZMGS08TJOwb7A08F80MemcdHINfKu32W0vH+IkXftM80JzMMwcooM0HPx42rYm0kmYovhOdoDDOGXWEbBInQ7aTIQkW4/UkytdIVP3uk5sRoPVollMeu1FnvBBe0xU2xMCSTpd4gOQXGHittmYhgK2YCNdrKBRn/k/wULS9RsejCPV4FkUAeiZK2JSFphR4YZPddo3XLb/mUXwgoikAovQ4RyN2QqW+PRXI7GEuY9UN/DuEaDsdBNyKEddeYJnMRepZmDQEQr5RMbn4B5ObFaODKmkMe4lg/GE9sb11AgMBAAGjggGWMIIBkjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCYGA1UdEQQfMB2BG2NvbnRhYmlsaWRhZEBkaWZlbmFzLmNvbS5wZTBGBgNVHSAEPzA9MDsGDSsGAQQBg5d3AAEAAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly9sbGFtYS5wZS9yZXBvc2l0b3J5LzAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jcmwwHQYDVR0OBBYEFOekfrMbj8D7XHBMrINSvd5DMeY9MA4GA1UdDwEB/wQEAwIGwDANBgkqhkiG9w0BAQsFAAOCAQEAfeOGCQjp79Wh+8REraPlhIb7EOjJsbVR7W9eYNnIo2jDZMpBY8yxrQtcukGBjABn63L+N335eviU1hX6saLTXwcCrNrsYNYt4hQ7FQWaHsSp9AckMdRCxDvhIpQVheVKLLT/aSOV1loEa6J8A25wk5n+oS1nEex6x+7k210FcLOREyVbPxqrlCJPX8SCVcJOl84KOHlbrGmPDp2r30IycyM3mx7cJfIwNCbcNOXi46QqI6QINhooysZy4lSj7LqIDItzG3YaXlnauWSGPEsl8ggqcCOp5QdXSAeov+0tkTD9/83OA6/5Qv5dvp0x3Qq5JY4H/QBrh16SHQcHqxIMPw==</X509Certificate></X509Data></KeyInfo></ds:Signature></ext:ExtensionContent></ext:UBLExtension></ext:UBLExtensions><cbc:UBLVersionID>2.1</cbc:UBLVersionID><cbc:CustomizationID>2.0</cbc:CustomizationID><cbc:ID>V002-0001358</cbc:ID><cbc:IssueDate>2026-05-29</cbc:IssueDate><cbc:IssueTime>13:11:15</cbc:IssueTime><cbc:DespatchAdviceTypeCode listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" listName="Tipo de Documento" listAgencyName="PE:SUNAT">31</cbc:DespatchAdviceTypeCode><cac:Signature><cbc:ID>V002-0001358</cbc:ID><cac:SignatoryParty><cac:PartyIdentification><cbc:ID>20555929006</cbc:ID></cac:PartyIdentification><cac:PartyName><cbc:Name><![CDATA[DISTRIBUIDORA FERRETERA NASCA S.A.C]]></cbc:Name></cac:PartyName></cac:SignatoryParty><cac:DigitalSignatureAttachment><cac:ExternalReference><cbc:URI>#SignatureSP</cbc:URI></cac:ExternalReference></cac:DigitalSignatureAttachment></cac:Signature><cac:DespatchSupplierParty><cac:Party><cac:Line truncated
@@ -0,0 +1,38 @@
[
{
"ruc_emisor": "20555929006",
"razon_social_emisor": "A. BERIO Y CIA S.A.C.",
"fecha_emision": "29/05/2026",
"tipo": "09",
"serie": "F004",
"numero": "3544",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/005_correo/20555929006-09-F004-3544.pdf"
},
{
"ruc_emisor": "20555929006",
"razon_social_emisor": null,
"fecha_emision": null,
"tipo": "31",
"serie": "V002",
"numero": "1358",
"ruc_receptor": "20100029741",
"razon_social_receptor": null,
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/005_correo/20555929006-31-V002-1358.pdf"
}
]
@@ -0,0 +1,105 @@
[
{
"ruc_emisor": "20555929006",
"razon_social_emisor": "DIFENAS S.A.C.",
"fecha_emision": "2026-05-29",
"tipo": "01",
"serie": "F004",
"numero": "3544",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 644.35,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129721"
},
{
"tipo": "31",
"serie": "V002",
"numero": "1358"
}
],
"tiene_detraccion": true,
"codigo_detraccion": "027",
"porcentaje_detraccion": 4.0,
"monto_detraccion": 26.0,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "POR EL SERVICIO DE TRANSPORTE DE 481 BOLSAS DE CEMENTO SOL G.R.R.\t\t: T001-00129721\nG.R.T.\t\t: V002-0001358\nP. PARTIDA \t: AV. ATOCONGO 1860 VMT LIMA LIMA\nP. LLEGADA \t: AV. PROLONGACION SAENZ PEÑA-LAS PALMAS MZ H LT.7 OTR.UCV 12203 SUBL B SECTOR CODIGO CATASTRAL\nCONF. VEHIC\t: T3S3\nVALOR REFER\t:S/ 644.35\nT",
"cantidad": 481.0,
"precio_unitario": 1.3396,
"total_linea": 546.06,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129721"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V002",
"numero": "1358"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/005_correo/20555929006-01-F004-3544.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20555929006",
"razon_social_emisor": "DISTRIBUIDORA FERRETERA NASCA S.A.C",
"fecha_emision": "2026-05-29",
"tipo": "31",
"serie": "V002",
"numero": "1358",
"ruc_receptor": "20550744024",
"razon_social_receptor": "CONSORCIO UNION FERRETERA S.A.C. - CONSUFER",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV ATOCONGO 1860 VMT LIMA LIMA",
"punto_llegada": "AV. PROLONGACION SAENZ PENA - LAS PALMAS MZA. H LOTE. 7 OTR. UCV 12203 SUBL B SECTOR CODIGO CATAST",
"peso_bruto": "20.443",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-29",
"vehiculo_principal": "AYI792",
"vehiculo_secundario": "D8L996",
"licencia_conductor": "Q09251892",
"nombre_conductor": "CARLOS",
"documento_conductor": "09251892",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO SOL T1",
"cantidad": 481.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/005_correo/20555929006-31-V002-1358.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,267 @@
<?xml version="1.0" encoding="ISO-8859-1"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 ..\xsd\maindoc\UBLPE-Invoice-2.0.xsd" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="SF011-00002786" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>0bVP8AHmCYXyChygo3XQvXBECu0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>GF4e84jreud/2MfjRC07GPolo3Stke3dGsFachUWZMmdK3DVwD5eYPYaJ58uKPJCzgTn91ZiNPuz
WMCrsW5hllzjD9VXaZVbna34EgzfdsbZml08nKXY2gi0n6cHo7WVfbDSCzx3fkuPfJc2iKnXmh9y
owPQG0kFcCnsIVYNaudMNvSrtDptHIcLpEcCCpEL2+cED4RW8goDByzVVEyYMaTbDQ67fvtq9EcM
4jqf877A7i4oYqKKJ1dyGG7VYIzTft8XGtV2uJjcjd3HQSvyqzSuGVkKIvbcp2q5/gxw5TvuyH6C
tNk3cFIYN7l7gSzUzkbJiw58+Kms8L4neWuaGg==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509SubjectName>C=PE,L=LIMA,O=TREFILADOS EL SALVADOR S R L,OU=Validado por Llama.pe ER,OU=FACTURA ELECTRONICA RUC 20100811571,CN=SOFTWARE DE FACTURACION ELECTRONICA,1.2.840.113549.1.9.1=#161b636f6e746162696c6964616440747265666973612e636f6d2e7065,STREET=AV. PABLO PATRON NRO. 211 URB. SAN PABLO</ds:X509SubjectName><ds:X509Certificate>MIIFgzCCBGugAwIBAgIIWD+k8QQbj9IwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEu
cGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcN
MjUxMTE5MTk1NTI2WhcNMjcxMTE5MTk1NTAwWjCCASExMTAvBgNVBAkMKEFWLiBQQUJMTyBQQVRS
T04gTlJPLiAyMTEgVVJCLiBTQU4gUEFCTE8xKjAoBgkqhkiG9w0BCQEWG2NvbnRhYmlsaWRhZEB0
cmVmaXNhLmNvbS5wZTEsMCoGA1UEAwwjU09GVFdBUkUgREUgRkFDVFVSQUNJT04gRUxFQ1RST05J
Q0ExLDAqBgNVBAsMI0ZBQ1RVUkEgRUxFQ1RST05JQ0EgUlVDIDIwMTAwODExNTcxMSEwHwYDVQQL
DBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxJTAjBgNVBAoMHFRSRUZJTEFET1MgRUwgU0FMVkFE
T1IgUyBSIEwxDTALBgNVBAcMBExJTUExCzAJBgNVBAYTAlBFMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAyYlT09RvuC+D9hLK0LGN9iOOC72e3Q5UqAPUQDCB+X58MOjuaHsTGyOZ1s9h
1jp9Wi2oigztr6YgChuWE7aVyHeMre2uI5t2rug2kuCwAnYQOrFtMZXkWZm1FOvL55mMGdMzryx2
E7/NDE1AvIfybsfx1JqH0HwnbHdb3usP67mhElFzgBwljwpfO9y3TmkrID5/o/DRXxlyLm2Up5AL
9cTdzDRngGV3eqeDFmrsLz1Ii2TBfDKJSpLKbMAZ0QE3mkfFolAaq/7s0+lLA1jvdw+be+3MF9sr
WQexVMlWZ0GUdduJk0+b8/ye85JEjQz/aVn+vQzx7MHXqPlCELDUCQIDAQABo4IBljCCAZIwDAYD
VR0TAQH/BAIwADAfBgNVHSMEGDAWgBRdiFut62X7/mii5NlvPVdyou8rmTBnBggrBgEFBQcBAQRb
MFkwNQYIKwYBBQUHMAKGKWh0dHA6Ly9jcnQubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY2Vy
MCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5sbGFtYS5wZTAmBgNVHREEHzAdgRtjb250YWJpbGlk
YWRAdHJlZmlzYS5jb20ucGUwRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUH
AgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsG
AQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5k
YXJkY2EuY3JsMB0GA1UdDgQWBBSJOvNq/vY9qi0s/jya/Pg3HEoWQDAOBgNVHQ8BAf8EBAMCBsAw
DQYJKoZIhvcNAQELBQADggEBAFv2Zeg2ylvyA7dO4hPIJ3e/WSQyqmf1BQUC2YOq4bwIF7z4qU4y
qlecjLsRI5NpDAdZHp6YNLRMOr6FxocKqm665RKjk7XTEY9DDW2sWdOhybreY+ikm6/LH3O3whMI
uONPgQQmHKUaEZ85oWKlPBCjjDSLwGxb5hyHYfgAh2whOFMBprc0y53DkkYMd7N6FFs+Zgirq91I
WxoW1vOkFTqc4RyFQ/KBJqwjsRYZ39g2LFJO4MByYWzjZn3TcJOj8BQAYrC2UzHKSl1BZuDW7r4i
+6r92zt5UrhxRE45isPgZuDkryZtRshSE5+i1KoK76IRg9LiCxh7xme7vfwjqdM=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></ext:ExtensionContent>
</ext:UBLExtension>
<ext:UBLExtension>
<ext:ExtensionContent>
<sac:AdditionalInformation>
<sac:AdditionalProperty>
<cbc:ID>1000</cbc:ID>
<cbc:Value><![CDATA[OCHOCIENTOS SETENTA Y 74/100 SOLES]]></cbc:Value>
</sac:AdditionalProperty>
<sac:AdditionalProperty>
<cbc:ID>2006</cbc:ID>
<cbc:Value><![CDATA[Operación sujeta a detracción]]></cbc:Value>
</sac:AdditionalProperty>
</sac:AdditionalInformation>
</ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>F011-00002786</cbc:ID>
<cbc:IssueDate>2026-05-30</cbc:IssueDate>
<cbc:InvoiceTypeCode listAgencyName="PE:SUNAT" listID="1001" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">01</cbc:InvoiceTypeCode>
<cbc:Note languageLocaleID="1000"><![CDATA[OCHOCIENTOS SETENTA Y 74/100 SOLES]]></cbc:Note>
<cbc:Note languageLocaleID="2006"><![CDATA[Operación sujeta a detracción]]></cbc:Note>
<cbc:DocumentCurrencyCode listAgencyName="United Nations Economic Commission for Europe" listID="ISO 4217 Alpha" listName="Currency">PEN</cbc:DocumentCurrencyCode>
<cac:DespatchDocumentReference>
<cbc:ID>-00000000</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">09</cbc:DocumentTypeCode>
</cac:DespatchDocumentReference>
<cac:Signature>
<cbc:ID>SF011-00002786</cbc:ID>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20100811571</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[SUNAT]]></cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>SF011-00002786</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100811571</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[TREFILADOS EL SALVADOR SRL]]></cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[TREFILADOS EL SALVADOR SRL]]></cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:ID>150115</cbc:ID>
<cbc:AddressTypeCode listAgencyName="PE:SUNAT" listName="Establecimientos anexos">0000</cbc:AddressTypeCode>
<cbc:StreetName><![CDATA[AV. PABLO PATRON 211-223 URB.SAN PABLO]]></cbc:StreetName>
<cbc:CityName>LIMA</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>LA VICTORIA</cbc:District>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. PABLO PATRON 211-223 URB.SAN PABLO]]></cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode listAgencyName="United Nations Economic Commission for Europe" listID="ISO 3166-1" listName="Country">PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA S.A.C.]]></cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:CityName>LIMA</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>VILLA MARIA DEL TRIUNFO</cbc:District>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. 26 DE NOVIEMBRE NRO. 2473 A.H. VIRGEN DE LOURDES (PARADERO 14 NUEVA ESPERANZA - PISO 2)]]></cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode listAgencyName="United Nations Economic Commission for Europe" listID="ISO 3166-1" listName="Country">PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:ID>Detraccion</cbc:ID>
<cbc:PaymentMeansCode>001</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>00063008982</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:ID>Detraccion</cbc:ID>
<cbc:PaymentMeansID schemeAgencyName="PE:SUNAT" schemeName="Codigo de detraccion" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo54">027</cbc:PaymentMeansID>
<cbc:PaymentPercent>4.0</cbc:PaymentPercent>
<cbc:Amount currencyID="PEN">34.83</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Credito</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">870.74</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Cuota001</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">870.74</cbc:Amount>
<cbc:PaymentDueDate>2026-06-14</cbc:PaymentDueDate>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">132.82</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">737.92</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">132.82</cbc:TaxAmount>
<cac:TaxCategory>
<cac:TaxScheme>
<cbc:ID schemeAgencyID="6" schemeID="UN/ECE 5153">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="PEN">737.92</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="PEN">737.92</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="PEN">870.74</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="PEN">870.74</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="PEN">737.92</cbc:LineExtensionAmount>
<cac:PricingReference>
<cac:AlternativeConditionPrice>
<cbc:PriceAmount currencyID="PEN">870.7400000000</cbc:PriceAmount>
<cbc:PriceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Precio" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo16">01</cbc:PriceTypeCode>
</cac:AlternativeConditionPrice>
</cac:PricingReference>
<cac:Delivery>
<cac:DeliveryLocation>
<cac:Address>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150142</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[MZ. G LTE 8,9 Z.I. PARQUE INDUSTRIAL]]></cbc:Line>
</cac:AddressLine>
</cac:Address>
</cac:DeliveryLocation>
<cac:Despatch>
<cbc:Instructions><![CDATA[650 BLS CEMENTO SOL TIPO I X S/1.3396]]></cbc:Instructions>
<cac:DespatchAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. ATOCONGO N° 1860]]></cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
</cac:Despatch>
<cac:DeliveryTerms>
<cbc:ID>01</cbc:ID>
<cbc:Amount currencyID="PEN">870.74</cbc:Amount>
</cac:DeliveryTerms>
<cac:DeliveryTerms>
<cbc:ID>02</cbc:ID>
<cbc:Amount currencyID="PEN">870.74</cbc:Amount>
</cac:DeliveryTerms>
<cac:DeliveryTerms>
<cbc:ID>03</cbc:ID>
<cbc:Amount currencyID="PEN">870.74</cbc:Amount>
</cac:DeliveryTerms>
<cac:Shipment>
<cbc:ID>01</cbc:ID>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
</cac:TransportEquipment>
<cac:MeasurementDimension>
<cbc:AttributeID>01</cbc:AttributeID>
<cbc:Measure unitCode="TNE">870.74</cbc:Measure>
</cac:MeasurementDimension>
</cac:TransportHandlingUnit>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
<cbc:SizeTypeCode listAgencyName="PE:MTC" listName="Configuracion Vehicular"><![CDATA[T3 S3]]></cbc:SizeTypeCode>
<cbc:ReturnabilityIndicator>false</cbc:ReturnabilityIndicator>
<cac:Delivery>
<cac:DeliveryTerms>
<cbc:Amount currencyID="PEN">102.27</cbc:Amount>
</cac:DeliveryTerms>
</cac:Delivery>
</cac:TransportEquipment>
<cac:MeasurementDimension>
<cbc:AttributeID>02</cbc:AttributeID>
<cbc:Measure unitCode="TNE">27.63</cbc:Measure>
</cac:MeasurementDimension>
</cac:TransportHandlingUnit>
</cac:Shipment>
</cac:Delivery>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">132.82</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">737.92</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">132.82</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:Percent>18.00000</cbc:Percent>
<cbc:TaxExemptionReasonCode>10</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID schemeAgencyID="6" schemeID="UN/ECE 5153">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description><![CDATA[SERVICIO DE TRANSPORTE]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>SER02</cbc:ID>
</cac:SellersItemIdentification>
<cac:AdditionalItemProperty>
<cbc:Name>CodigoUnidad</cbc:Name>
<cbc:Value>UNIDAD</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="PEN">737.9152540000</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="ISO-8859-1"?><DespatchAdvice xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="SignSUNAT" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>J3fFwys+URZdbF0QevDNGBPJe5E=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>jaTUTseluikf9R3QQRSjdKYvNMa67tuh+heIBuozY2mQicAIhbFEnST+EG9CpsqzGuW3vy58JGaO
/MDvBTnF6wBJLftnGRUdC1YL/XVEx2XU5Z6V0xpG78E2fqwR7Wn89/8Nizoiyd4DiFlhPMTXoTBd
VXV9tIxl58CMS0QnzNOP+bHB6dvLhAxhWUYx6iknqWTxrudp1QiVv+npUdxIIRxH8+nSEfXsnJvJ
908fQ9Wuub0hBX/3Bh6S5LIBuZd4Ty1bsSvihR3MJrR+sVC03t2X3zhBmEPXGc1NpeAQkKhwe9/+
o9vvRFUHQiMlFGbA+TrIAxWRCz1ebhSl+P0GaA==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509SubjectName>C=PE,L=LIMA,O=TREFILADOS EL SALVADOR S R L,OU=Validado por Llama.pe ER,OU=FACTURA ELECTRONICA RUC 20100811571,CN=SOFTWARE DE FACTURACION ELECTRONICA,1.2.840.113549.1.9.1=#161b636f6e746162696c6964616440747265666973612e636f6d2e7065,STREET=AV. PABLO PATRON NRO. 211 URB. SAN PABLO</ds:X509SubjectName><ds:X509Certificate>MIIFgzCCBGugAwIBAgIIWD+k8QQbj9IwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEu
cGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcN
MjUxMTE5MTk1NTI2WhcNMjcxMTE5MTk1NTAwWjCCASExMTAvBgNVBAkMKEFWLiBQQUJMTyBQQVRS
T04gTlJPLiAyMTEgVVJCLiBTQU4gUEFCTE8xKjAoBgkqhkiG9w0BCQEWG2NvbnRhYmlsaWRhZEB0
cmVmaXNhLmNvbS5wZTEsMCoGA1UEAwwjU09GVFdBUkUgREUgRkFDVFVSQUNJT04gRUxFQ1RST05J
Q0ExLDAqBgNVBAsMI0ZBQ1RVUkEgRUxFQ1RST05JQ0EgUlVDIDIwMTAwODExNTcxMSEwHwYDVQQL
DBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxJTAjBgNVBAoMHFRSRUZJTEFET1MgRUwgU0FMVkFE
T1IgUyBSIEwxDTALBgNVBAcMBExJTUExCzAJBgNVBAYTAlBFMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAyYlT09RvuC+D9hLK0LGN9iOOC72e3Q5UqAPUQDCB+X58MOjuaHsTGyOZ1s9h
1jp9Wi2oigztr6YgChuWE7aVyHeMre2uI5t2rug2kuCwAnYQOrFtMZXkWZm1FOvL55mMGdMzryx2
E7/NDE1AvIfybsfx1JqH0HwnbHdb3usP67mhElFzgBwljwpfO9y3TmkrID5/o/DRXxlyLm2Up5AL
9cTdzDRngGV3eqeDFmrsLz1Ii2TBfDKJSpLKbMAZ0QE3mkfFolAaq/7s0+lLA1jvdw+be+3MF9sr
WQexVMlWZ0GUdduJk0+b8/ye85JEjQz/aVn+vQzx7MHXqPlCELDUCQIDAQABo4IBljCCAZIwDAYD
VR0TAQH/BAIwADAfBgNVHSMEGDAWgBRdiFut62X7/mii5NlvPVdyou8rmTBnBggrBgEFBQcBAQRb
MFkwNQYIKwYBBQUHMAKGKWh0dHA6Ly9jcnQubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY2Vy
MCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5sbGFtYS5wZTAmBgNVHREEHzAdgRtjb250YWJpbGlk
YWRAdHJlZmlzYS5jb20ucGUwRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUH
AgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsG
AQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5k
YXJkY2EuY3JsMB0GA1UdDgQWBBSJOvNq/vY9qi0s/jya/Pg3HEoWQDAOBgNVHQ8BAf8EBAMCBsAw
DQYJKoZIhvcNAQELBQADggEBAFv2Zeg2ylvyA7dO4hPIJ3e/WSQyqmf1BQUC2YOq4bwIF7z4qU4y
qlecjLsRI5NpDAdZHp6YNLRMOr6FxocKqm665RKjk7XTEY9DDW2sWdOhybreY+ikm6/LH3O3whMI
uONPgQQmHKUaEZ85oWKlPBCjjDSLwGxb5hyHYfgAh2whOFMBprc0y53DkkYMd7N6FFs+Zgirq91I
WxoW1vOkFTqc4RyFQ/KBJqwjsRYZ39g2LFJO4MByYWzjZn3TcJOj8BQAYrC2UzHKSl1BZuDW7r4i
+6r92zt5UrhxRE45isPgZuDkryZtRshSE5+i1KoK76IRg9LiCxh7xme7vfwjqdM=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>V002-00004575</cbc:ID>
<cbc:IssueDate>2026-05-30</cbc:IssueDate>
<cbc:IssueTime>16:21:18</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">31</cbc:DespatchAdviceTypeCode>
<cbc:Note>CONFIGURACION VEHICULAR T3 S3</cbc:Note>
<cac:AdditionalDocumentReference>
<cbc:ID>T001-00129813</cbc:ID>
<cbc:DocumentTypeCode listAgencyName="PE:SUNAT" listName="Documento relacionado al transporte" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo61">09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guia de Remision Remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:Signature>
<cbc:ID>SV002-00004575</cbc:ID>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20100811571</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name><![CDATA[SUNAT]]></cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>SignSUNAT</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:DespatchSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100811571</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[TREFILADOS EL SALVADOR SRL]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<cac:DeliveryCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100811571</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[TREFILADOS EL SALVADOR SRL]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<cac:OriginatorCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA. S. A. C.]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:OriginatorCustomerParty>
<cac:Shipment>
<cbc:ID>SUNAT_Envio</cbc:ID>
<cbc:GrossWeightMeasure unitCode="KGM">27625.00</cbc:GrossWeightMeasure>
<cbc:SpecialInstructions>SUNAT_Envio_IndicadorPagadorFlete_Remitente</cbc:SpecialInstructions>
<cac:ShipmentStage>
<cac:TransitPeriod>
<cbc:StartDate>2026-05-30</cbc:StartDate>
</cac:TransitPeriod>
<cac:CarrierParty>
<cac:PartyLegalEntity>
<cbc:CompanyID>151263CNG</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:CarrierParty>
<cac:DriverPerson>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="1" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">09722754</cbc:ID>
<cbc:FirstName>EMILIANO</cbc:FirstName>
<cbc:FamilyName>CERVANTES TINOCO</cbc:FamilyName>
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<cbc:ID>Q09722754</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
</cac:ShipmentStage>
<cac:Delivery>
<cac:DeliveryAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150142</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[PQ. IND. MZ. G LTE 8,9 (LIMA-LIMA-VILLA EL SALVADOR)]]></cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<cac:DespatchAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<cac:AddressLine>
<cbc:Line><![CDATA[AV. ATOCONGO N° 1860 (LIMA-LIMA-VILLA MARIA DEL TRIUNFO)]]></cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="6" schemeName="Documento de Identidad" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName><![CDATA[A. BERIO Y CIA. S. A. C.]]></cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
<cbc:ID>B7C885</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>15M22019210E</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
<cac:ShipmentDocumentReference>
<cbc:ID schemeAgencyName="PE:SUNAT" schemeID="06" schemeName="Entidad Autorizadora">151263CNG</cbc:ID>
</cac:ShipmentDocumentReference>
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<cac:DespatchLine>
<cbc:ID>1</cbc:ID>
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListAgencyName="United Nations Economic Commission for Europe" unitCodeListID="UN/ECE rec 20">650</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description><![CDATA[$it.Descripcion]]></cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>CEM01</cbc:ID>
</cac:SellersItemIdentification>
<cac:AdditionalItemProperty>
<cbc:Name>Indicador de bien normalizado</cbc:Name>
<cbc:NameCode listAgencyName="PE:SUNAT" listName="Propiedad del item" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo55">7022</cbc:NameCode>
<cbc:Value>0</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
</cac:DespatchLine>
</DespatchAdvice>
@@ -0,0 +1,29 @@
[
{
"ruc_emisor": "20100811571",
"razon_social_emisor": null,
"fecha_emision": "30/05/2026",
"tipo": "01",
"serie": "T001",
"numero": "129813",
"ruc_receptor": "20100029741",
"razon_social_receptor": null,
"total_factura": "870.74",
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": null,
"porcentaje_detraccion": "4.0",
"monto_detraccion": "34.83",
"lineas": [
{
"descripcion": "Medida SERVICIO DE TRANSPORTE NIU S/",
"cantidad": "870.74",
"precio_unitario": "870.74",
"total_linea": "737.92",
"guias_remision": [],
"guia_transportista": null
}
],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/006_correo/20100811571-01-T001-129813.pdf"
}
]
@@ -0,0 +1,90 @@
[
{
"ruc_emisor": "20100811571",
"razon_social_emisor": "TREFILADOS EL SALVADOR SRL",
"fecha_emision": "2026-05-30",
"tipo": "01",
"serie": "F011",
"numero": "2786",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 870.74,
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": "027",
"porcentaje_detraccion": 4.0,
"monto_detraccion": 34.83,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERVICIO DE TRANSPORTE",
"cantidad": 1.0,
"precio_unitario": 870.74,
"total_linea": 737.92,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/006_correo/20100811571-01-F011-2786.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20100811571",
"razon_social_emisor": "TREFILADOS EL SALVADOR SRL",
"fecha_emision": "2026-05-30",
"tipo": "31",
"serie": "V002",
"numero": "4575",
"ruc_receptor": "20100811571",
"razon_social_receptor": "TREFILADOS EL SALVADOR SRL",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129813"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO N° 1860 (LIMA-LIMA-VILLA MARIA DEL TRIUNFO)",
"punto_llegada": "PQ. IND. MZ. G LTE 8,9 (LIMA-LIMA-VILLA EL SALVADOR)",
"peso_bruto": "27625.00",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-30",
"vehiculo_principal": "B7C885",
"vehiculo_secundario": null,
"licencia_conductor": "Q09722754",
"nombre_conductor": "EMILIANO",
"documento_conductor": "09722754",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "$it.Descripcion",
"cantidad": 650.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/006_correo/20100811571-31-V002-4575.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<DespatchAdvice xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2">
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="NubeFacTSign">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>pGFus9mok5BsHOvwP9jXVzznwtgwSX8F9LIyJYnikxk=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>jJovOUNvc7dTBurWODbsH4GaeGH3N7bFZkPjfHCS3BSNiQH4YR6ykNGLAzZY8M2SgP+y/RNHrUI3ZMfo+WQ6dXjXJYiC95lnYY3zf+KKT9XI/nsPztOUCtm8yyCO5W2zuZQkh9WQRJCg0G8TKwd7mf/gZ5xWurVWSzlMo9tyz+gRIyFsnt+nvSJ0Lx8d0VduUrNVsUBG31ruSdAavU0Dm5r86njdnlPLVnXVy42FMjlCAmgNEedtkFBQ0+ipCfTuCis8gUv5XI6GPBrCuwR3c1nO9Bmh+zVuijc4J1R6jLuPvRT96iMykyB1UXatK0isFcHG209moHUlpfn+C0OEoQ==</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>street=CAL. LIBERTAD NRO. 176 INT. 211,emailAddress=sistemas@nubefact.com,CN=NUBEFACT WEB,OU=20600695771,OU=Validado por Llama.pe ER,O=NUBEFACT S.A.,L=LIMA,C=PE</ds:X509SubjectName>
<ds:X509Certificate>MIIFLzCCBBegAwIBAgIIeqYdzau699YwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMzA1MTgzODI1WhcNMjkwMzA0MTgzODAwWjCB1DEoMCYGA1UECQwfQ0FMLiBMSUJFUlRBRCBOUk8uIDE3NiBJTlQuIDIxMTEkMCIGCSqGSIb3DQEJARYVc2lzdGVtYXNAbnViZWZhY3QuY29tMRUwEwYDVQQDDAxOVUJFRkFDVCBXRUIxFDASBgNVBAsMCzIwNjAwNjk1NzcxMSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxFjAUBgNVBAoMDU5VQkVGQUNUIFMuQS4xDTALBgNVBAcMBExJTUExCzAJBgNVBAYTAlBFMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3PnivJidKntheakfk5pA2SOXyG66a1FQtSW1BtXMpN2C953XT9iJLbR9kD/2hUsTs08drxdkSeKJwyMvUL41VpGX54PkSBXg1nqwbNN9dwj+zSd1PsFcmLtpEizyU4r6B8PSo07cdAzisAhnt3F1X/LcpcuJcUT3x88RJt18vSbh903Ihvp6BV7BCkJk/MJSRKeJnVMYJPtrgLvTxH03DUs6mIozR673WmYZAdzXPo1Kv0JpfrP/XTcaZECe2VQ+l7OEQS/s2bSjL7K8VgvTW+PtkCGZu9q6UUYsr3F1UL7TVad9zEFgL7rMQvELT7r+YWCP4AwVfTrXvqAoMednKQIDAQABo4IBkDCCAYwwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBRdiFut62X7/mii5NlvPVdyou8rmTBnBggrBgEFBQcBAQRbMFkwNQYIKwYBBQUHMAKGKWh0dHA6Ly9jcnQubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5sbGFtYS5wZTAgBgNVHREEGTAXgRVzaXN0ZW1hc0BudWJlZmFjdC5jb20wRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY3JsMB0GA1UdDgQWBBSZK2idJe/W7w+eTwXNhJ/o8XK6pjAOBgNVHQ8BAf8EBAMCBsAwDQYJKoZIhvcNAQELBQADggEBAIEF6UtQGQ+yLKVVRCdiogInGUGv7IECqg9gMQNZBZ2ZP9SgQOOTFIsDI0psbS3D6u/wJ2vgf78a287ysqJUcGqhW1+plQh4toN3v8bCqwXACTIYW6bUU5bOab0QY//xrLkNZ8Iwa/M/LZstU3dgZjIwXtcMD+ANAIynN4rcNNgeVja8lnbYU3WQ1u9UIjMxtSp1rLf4mwTGSeLI1uuAWgWczkMaFjMF1RJhX9VlKX8qDC+pr/jiu8LTHt8VDeVvDwpZ4qVbI/8qq+VUyXjBffcDdeY5qb75nnlhEMHLey3t/GcEo80nmtjTKmBdroVkooIat7PcSvfQQ+kzrSnV2as=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
</ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>VPP1-516</cbc:ID>
<cbc:IssueDate>2026-05-29</cbc:IssueDate>
<cbc:IssueTime>16:28:14</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode>31</cbc:DespatchAdviceTypeCode>
<cbc:Note>Obs: MARCA VEHICULO: VOLVO N CONF.VEHICULAR: T2 S2</cbc:Note>
<cac:AdditionalDocumentReference>
<cbc:ID>T001-129749</cbc:ID>
<cbc:DocumentTypeCode>09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de Remisión Remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:Signature>
<cbc:ID>10091153136</cbc:ID>
<cbc:Note>WWW.NUBEFACT.COM</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>ZAPATA ARAPA EUSEBIO</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>10091153136</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:DespatchSupplierParty>
<cbc:CustomerAssignedAccountID schemeID="6">10091153136</cbc:CustomerAssignedAccountID>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ZAPATA ARAPA EUSEBIO</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<cac:DeliveryCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ZAPATA ARAPA EUSEBIO</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<cac:Shipment>
<cbc:ID>SUNAT_Envio</cbc:ID>
<cbc:GrossWeightMeasure unitCode="TNE">22.1</cbc:GrossWeightMeasure>
<cac:ShipmentStage>
<cac:TransitPeriod>
<cbc:StartDate>2026-05-29</cbc:StartDate>
</cac:TransitPeriod>
<cac:CarrierParty>
<cac:PartyLegalEntity>
<cbc:CompanyID>1516491CNG</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:CarrierParty>
<cac:TransportMeans>
<cac:RoadTransport>
<cbc:LicensePlateID>ABB758</cbc:LicensePlateID>
</cac:RoadTransport>
</cac:TransportMeans>
<cac:DriverPerson>
<cbc:ID schemeID="1">42327525</cbc:ID>
<cbc:FirstName>LUIS AUGUSTO</cbc:FirstName>
<cbc:FamilyName>YARANGA MARTINEZ</cbc:FamilyName>
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<cbc:ID>Q42327525</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
</cac:ShipmentStage>
<cac:Delivery>
<cac:DeliveryAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150142</cbc:ID>
<cac:AddressLine>
<cbc:Line>PQ. INDUSTRIAL-AV. 1 DE MAYO MZ. C LT. 17- ADEICOSUR- PARC. 1 -VILLA EL SALVADOR - LIMA - LIMA</cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<cac:DespatchAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<cac:AddressLine>
<cbc:Line>AV. ATOCONGO 1860 VILLA MARIA DE L TRIUNFO</cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>A. BERIO Y CIA S.A.C.</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
<cbc:ID>ABB758</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>0151601857</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
<cac:AttachedTransportEquipment>
<cbc:ID>A6T980</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>0151834688</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
</cac:AttachedTransportEquipment>
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cac:DespatchLine>
<cbc:ID>1</cbc:ID>
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListID="UN/ECE rec 20" unitCodeListAgencyName="United Nations Economic Commission for Europe">520.0</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>CEMENTO SOL 42.50KG</cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>CEM01</cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
</DespatchAdvice>
@@ -0,0 +1,49 @@
[
{
"ruc_emisor": "10091153136",
"razon_social_emisor": "ZAPATA ARAPA EUSEBIO",
"fecha_emision": "2026-05-29",
"tipo": "31",
"serie": "VPP1",
"numero": "516",
"ruc_receptor": "10091153136",
"razon_social_receptor": "ZAPATA ARAPA EUSEBIO",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129749"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860 VILLA MARIA DE L TRIUNFO",
"punto_llegada": "PQ. INDUSTRIAL-AV. 1 DE MAYO MZ. C LT. 17- ADEICOSUR- PARC. 1 -VILLA EL SALVADOR - LIMA - LIMA",
"peso_bruto": "22.1",
"unidad_peso_bruto": "TNE",
"fecha_traslado": "2026-05-29",
"vehiculo_principal": "ABB758",
"vehiculo_secundario": "A6T980",
"licencia_conductor": "Q42327525",
"nombre_conductor": "LUIS AUGUSTO",
"documento_conductor": "42327525",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO SOL 42.50KG",
"cantidad": 520.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/007_correo/10091153136-31-VPP1-516.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<Invoice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent><Signature Id="SignatureSP" xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>6NbSu0QEYb5HGQuf2o/fytmMu+4=</DigestValue></Reference></SignedInfo><SignatureValue>TtpCyWi8axTzZ1OX/x50gQmukTLf9RHEbkAD2f/RoraA4QzJU/LzVHt14Ld1wJaSRpPPrniWaIqz6NOn0PvBWDwJtpNSV01DiO1n9GiJa55jKlZG4DijFie+gbQcrkVepFcbGEb+sC1dqm+Z6Ag7bdXrt0V1PbQ8OTmghh0bKvi3g5rK6Zs+chENStqEzKLJ9lynMpLrFFBEl/2wH4pBCd2cxwphViBH7Vqq6on5k6HvQ0FzqhTjnXKVkdNTfjh3p6hZp4PDuaOmxNYJYtb18cyoWJlJJIboB22ur4ImZ65SLP4iIBdHmTru8HoI3Z0H9Huxel/veW2rnb2OepLywQ==</SignatureValue><KeyInfo><X509Data><X509SubjectName>C=PE, L=LIMA, O=DISTRIBUIDORA SANTA PATRICIA S R L, OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20154645005, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=dis_patricia@yahoo.com, STREET=AV. TOMAS MARSANO NRO. 4330 URB. LA VIRREYNA</X509SubjectName><X509Certificate>MIIFgzCCBGugAwIBAgIIGTcLfd6CAQswDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDE0MTgzNDIxWhcNMjYxMDE0MTgzNDAwWjCCASYxNTAzBgNVBAkMLEFWLiBUT01BUyBNQVJTQU5PIE5STy4gNDMzMCBVUkIuIExBIFZJUlJFWU5BMSUwIwYJKoZIhvcNAQkBFhZkaXNfcGF0cmljaWFAeWFob28uY29tMSwwKgYDVQQDDCNTT0ZUV0FSRSBERSBGQUNUVVJBQ0lPTiBFTEVDVFJPTklDQTEsMCoGA1UECwwjRkFDVFVSQSBFTEVDVFJPTklDQSBSVUMgMjAxNTQ2NDUwMDUxITAfBgNVBAsMGFZhbGlkYWRvIHBvciBMbGFtYS5wZSBFUjErMCkGA1UECgwiRElTVFJJQlVJRE9SQSBTQU5UQSBQQVRSSUNJQSBTIFIgTDENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCmsa8aNxkqzWWk19zfoD/cIhArrgjNFx0TeKtdN+/m+6y8ucQDeqbjtnTHEcLjyeGiWNzowP+VLI9EJFOhsQBHjDMUxF33BYdRQw4Ii5u72voovryE8EAIsuHUkrGwnI4q+ZYq3s/MF/dDNr1Fq5gQYcWju0Qcw5uR18+LDZPxc7IDwWEA9UY0Qe4G49GJWLIcoOEf2wpzQ1+YolBS8PNny9H4a9LjG3+XkCdkVL676PjEykMF/aMEQkSNPkkjiHzl+ftiQXuzAMT/1IxvCwfjwzXHqmHM91jeLk5POw0PkqxfFAvYIBzodXMNygBihmkpAeuXgBpH4qo1unqfdETDAgMBAAGjggGRMIIBjTAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCEGA1UdEQQaMBiBFmRpc19wYXRyaWNpYUB5YWhvby5jb20wRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY3JsMB0GA1UdDgQWBBS5UPPPJNJvf5jr4HwdRfcu37ZOWTAOBgNVHQ8BAf8EBAMCBsAwDQYJKoZIhvcNAQELBQADggEBAGXcN3ocVIpJ/GKOQwlnK3vF49BDGRbXmhrB8wg/VekOUQI6Z4AUMlAcLzi+K2rKJLwbf0bw0psOdLT6Gu2X1DP4bxPaVAqePxjY0t4H2QBmm5MZ8jxJIFgYzf53Bhgu7slJk2bRPsGt8ujAX4EjqyjfhVOnzzLk2QOH/beZK+GsaXduRxvnfBDbknYysdFTDq8J1Hn0lWHjYFhb76doEputR9KYkrTHmqFFyeOyJF2+waJkfDPuVVjWTmYlLLZCzKFKxrjWF/paNE2cJm28QcoVUxBi8o4lq9BHhkBnO54tLZRtnptbMnUkAsmXZg8SmoPpaWzAkCISrGAoTIAXm2M=</X509Certificate></X509Data></KeyInfo></Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>F002-00052385</cbc:ID>
<cbc:IssueDate>2026-05-27</cbc:IssueDate>
<cbc:IssueTime>13:47:01</cbc:IssueTime>
<cbc:InvoiceTypeCode listID="0101" listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">01</cbc:InvoiceTypeCode>
<cbc:Note languageLocaleID="1000">QUINIENTOS NUEVE Y 05/100</cbc:Note>
<cbc:Note languageLocaleID="2006">OPERACION SUJETA A DETRACCION</cbc:Note>
<cbc:DocumentCurrencyCode listID="ISO 4217 Alpha" listAgencyName="United Nations Economic Commission for Europe" listName="Currency">PEN</cbc:DocumentCurrencyCode>
<cac:Signature>
<cbc:ID>20154645005</cbc:ID>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20154645005</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>DISTRIBUIDORA SANTA PATRICIA S R L</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>#SignatureKG</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20154645005</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>DISTRIBUIDORA SANTA PATRICIA S R L</cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:RegistrationName>DISTRIBUIDORA SANTA PATRICIA S R L</cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:AddressTypeCode>0000</cbc:AddressTypeCode>
<cbc:CityName>LIMA</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>SANTIAGO DE SURCO</cbc:District>
<cac:Country>
<cbc:IdentificationCode>PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>A. BERIO Y CIA S.A.C.</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode listAgencyName="PE:SUNAT" listName="Medio de pago" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo59">001</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>00000-451754</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:ID schemeName="Codigo de detraccion" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:g em:catalogos:catalogo54">027</cbc:ID>
<cbc:PaymentPercent>4</cbc:PaymentPercent>
<cbc:Amount currencyID="PEN">20</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Credito</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">489.05</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Cuota001</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">489.05</cbc:Amount>
<cbc:PaymentDueDate>2026-06-06</cbc:PaymentDueDate>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">77.65</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">431.40</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">77.65</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID schemeID="UN/ECE 5305" schemeName="Tax Category Identifier" schemeAgencyName="United Nations Economic Commission for Europe">S</cbc:ID>
<cac:TaxScheme>
<cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="PEN">431.4</cbc:LineExtensionAmount>
<cbc:TaxInclusiveAmount currencyID="PEN">509.05</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="PEN">0</cbc:AllowanceTotalAmount>
<cbc:PayableAmount currencyID="PEN">509.05</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="NIU" unitCodeListID="UN/ECE rec 20" unitCodeListAgencyName="United Nations Economic Commission for Europe">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="PEN">431.40</cbc:LineExtensionAmount>
<cac:PricingReference>
<cac:AlternativeConditionPrice>
<cbc:PriceAmount currencyID="PEN">509.050000</cbc:PriceAmount>
<cbc:PriceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Precio" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo16">01</cbc:PriceTypeCode>
</cac:AlternativeConditionPrice>
</cac:PricingReference>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">77.65</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">431.40</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">77.65</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID schemeID="UN/ECE 5305" schemeName="Tax Category Identifier" schemeAgencyName="United Nations Economic Commission for Europe">S</cbc:ID>
<cbc:Percent>18</cbc:Percent>
<cbc:TaxExemptionReasonCode listAgencyName="PE:SUNAT" listName="Afectacion del IGV" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo07">10</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID schemeID="UN/ECE 5153" schemeName="Codigo de tributos" schemeAgencyName="PE:SUNAT">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description>SERVICIO DE TRANSPORTE</cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>SE000001</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="UNSPSC" listAgencyName="GS1 US" listName="Item Classification">80141623</cbc:ItemClassificationCode>
</cac:CommodityClassification>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="PEN">431.398305</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<DespatchAdvice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent><Signature Id="SignatureSP" xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" /></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /><DigestValue>J59YkgHRtOUOgTDb+AsI0L9wUwo=</DigestValue></Reference></SignedInfo><SignatureValue>E9cCCqtUJWIhTHxgOmfjBVVrTj0vvKDOzSnYz+RQPgbQokF95kUVYv/I10d0y5MsgFQy+ivoNd4ULp5B0kI1CsEXRFlxb5ZPhur2LXpbnMzynmccOfmIbpp7ke5OE+5DNeeC/B6fCpoPp3sjBsD9WYDY2JktLcrIpXiRNeQycH3SIkOxsMSnQCfmqH7pn30kqO8nlJEcb0r0bCu8c/AvexYXW0iEWeKPgpkPyVlHjHGE01hVBhGy09oFspYepeu7T2rQEfGXsyYfJxNHzjdUvLmZJBkV5GZcMfAJ4DCXv1jazNydCDQbH4UnnAmQN3mgz0rO3HJDasKD5OQayPZ4cw==</SignatureValue><KeyInfo><X509Data><X509SubjectName>C=PE, L=LIMA, O=DISTRIBUIDORA SANTA PATRICIA S R L, OU=Validado por Llama.pe ER, OU=FACTURA ELECTRONICA RUC 20154645005, CN=SOFTWARE DE FACTURACION ELECTRONICA, E=dis_patricia@yahoo.com, STREET=AV. TOMAS MARSANO NRO. 4330 URB. LA VIRREYNA</X509SubjectName><X509Certificate>MIIFgzCCBGugAwIBAgIIGTcLfd6CAQswDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjQxMDE0MTgzNDIxWhcNMjYxMDE0MTgzNDAwWjCCASYxNTAzBgNVBAkMLEFWLiBUT01BUyBNQVJTQU5PIE5STy4gNDMzMCBVUkIuIExBIFZJUlJFWU5BMSUwIwYJKoZIhvcNAQkBFhZkaXNfcGF0cmljaWFAeWFob28uY29tMSwwKgYDVQQDDCNTT0ZUV0FSRSBERSBGQUNUVVJBQ0lPTiBFTEVDVFJPTklDQTEsMCoGA1UECwwjRkFDVFVSQSBFTEVDVFJPTklDQSBSVUMgMjAxNTQ2NDUwMDUxITAfBgNVBAsMGFZhbGlkYWRvIHBvciBMbGFtYS5wZSBFUjErMCkGA1UECgwiRElTVFJJQlVJRE9SQSBTQU5UQSBQQVRSSUNJQSBTIFIgTDENMAsGA1UEBwwETElNQTELMAkGA1UEBhMCUEUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCmsa8aNxkqzWWk19zfoD/cIhArrgjNFx0TeKtdN+/m+6y8ucQDeqbjtnTHEcLjyeGiWNzowP+VLI9EJFOhsQBHjDMUxF33BYdRQw4Ii5u72voovryE8EAIsuHUkrGwnI4q+ZYq3s/MF/dDNr1Fq5gQYcWju0Qcw5uR18+LDZPxc7IDwWEA9UY0Qe4G49GJWLIcoOEf2wpzQ1+YolBS8PNny9H4a9LjG3+XkCdkVL676PjEykMF/aMEQkSNPkkjiHzl+ftiQXuzAMT/1IxvCwfjwzXHqmHM91jeLk5POw0PkqxfFAvYIBzodXMNygBihmkpAeuXgBpH4qo1unqfdETDAgMBAAGjggGRMIIBjTAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFF2IW63rZfv+aKLk2W89V3Ki7yuZMGcGCCsGAQUFBwEBBFswWTA1BggrBgEFBQcwAoYpaHR0cDovL2NydC5sbGFtYS5wZS9sbGFtYXBlc3RhbmRhcmRjYS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmxsYW1hLnBlMCEGA1UdEQQaMBiBFmRpc19wYXRyaWNpYUB5YWhvby5jb20wRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY3JsMB0GA1UdDgQWBBS5UPPPJNJvf5jr4HwdRfcu37ZOWTAOBgNVHQ8BAf8EBAMCBsAwDQYJKoZIhvcNAQELBQADggEBAGXcN3ocVIpJ/GKOQwlnK3vF49BDGRbXmhrB8wg/VekOUQI6Z4AUMlAcLzi+K2rKJLwbf0bw0psOdLT6Gu2X1DP4bxPaVAqePxjY0t4H2QBmm5MZ8jxJIFgYzf53Bhgu7slJk2bRPsGt8ujAX4EjqyjfhVOnzzLk2QOH/beZK+GsaXduRxvnfBDbknYysdFTDq8J1Hn0lWHjYFhb76doEputR9KYkrTHmqFFyeOyJF2+waJkfDPuVVjWTmYlLLZCzKFKxrjWF/paNE2cJm28QcoVUxBi8o4lq9BHhkBnO54tLZRtnptbMnUkAsmXZg8SmoPpaWzAkCISrGAoTIAXm2M=</X509Certificate></X509Data></KeyInfo></Signature></ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>V001-00004156</cbc:ID>
<cbc:IssueDate>2026-05-25</cbc:IssueDate>
<cbc:IssueTime>00:00:00</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01">31</cbc:DespatchAdviceTypeCode>
<cbc:Note>SEGÚN: GR T001-0129437, CODIGO DE INSCRIPCION : 151903049</cbc:Note>
<cac:Signature>
<cbc:ID>20154645005</cbc:ID>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20154645005</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>DISTRIBUIDORA SANTA PATRICIA S R L</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>#SignatureKG</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:DespatchSupplierParty>
<cbc:CustomerAssignedAccountID schemeID="6">20154645005</cbc:CustomerAssignedAccountID>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20154645005</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>DISTRIBUIDORA SANTA PATRICIA SRL</cbc:RegistrationName>
<cbc:CompanyID>1525019CNG</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<cac:DeliveryCustomerParty>
<cbc:CustomerAssignedAccountID schemeID="6">20154645005</cbc:CustomerAssignedAccountID>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20154645005</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>DISTRIBUIDORA SANTA PATRICIA S R L</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<cac:Shipment>
<cbc:ID>SUNAT_Envio</cbc:ID>
<cbc:GrossWeightMeasure unitCode="KGM">16150</cbc:GrossWeightMeasure>
<cbc:SpecialInstructions>SUNAT_Envio_IndicadorPagadorFlete_Remitente</cbc:SpecialInstructions>
<cac:ShipmentStage>
<cac:CarrierParty>
<cac:PartyLegalEntity>
<cbc:CompanyID>1525019CNG</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:CarrierParty>
</cac:ShipmentStage>
<cac:ShipmentStage>
<cac:DriverPerson>
<cbc:ID schemeID="1" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">42833005</cbc:ID>
<cbc:FirstName>RUBEN</cbc:FirstName>
<cbc:FamilyName>PEREZ HUACHO</cbc:FamilyName>
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<cbc:ID>Q42833005</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
</cac:ShipmentStage>
<cac:ShipmentStage>
<cac:TransitPeriod>
<cbc:StartDate>2026-05-25</cbc:StartDate>
</cac:TransitPeriod>
</cac:ShipmentStage>
<cac:Delivery>
<cac:DeliveryAddress>
<cbc:ID schemeName="Ubigeos" schemeAgencyName="PE:INEI">150140</cbc:ID>
<cbc:StreetName>JR. LOMA DE LOS PENSAMIENTOS MZ. W4 LT. 7 URB. PROL. BENAVIDES</cbc:StreetName>
<cac:AddressLine>
<cbc:Line>JR. LOMA DE LOS PENSAMIENTOS MZ. W4 LT. 7 URB. PROL. BENAVIDES</cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<cac:DespatchAddress>
<cbc:ID schemeName="Ubigeos" schemeAgencyName="PE:INEI">150143</cbc:ID>
<cbc:StreetName>AV. ATOCONGO 1860</cbc:StreetName>
<cac:AddressLine>
<cbc:Line>AV. ATOCONGO 1860</cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>A. BERIO Y CIA S.A.C.</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
<cbc:ID>A1Y872</cbc:ID>
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<cac:DespatchLine>
<cbc:ID>1</cbc:ID>
<cbc:DeliveredQuantity unitCode="BG" unitCodeListID="UN/ECE rec 20" unitCodeListAgencyName="United Nations Economic Commission for Europe">380</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>CEMENTO APU</cbc:Description>
<cbc:Name>CEMENTO APU</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>CE000002</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="UNSPSC" listAgencyName="GS1 US" listName="Item Classification">30111601</cbc:ItemClassificationCode>
</cac:CommodityClassification>
</cac:Item>
</cac:DespatchLine>
</DespatchAdvice>
@@ -0,0 +1,57 @@
[
{
"ruc_emisor": "20154645005",
"razon_social_emisor": "A. BERIO Y CIA S.A.C. MONEDA: SOLES",
"fecha_emision": "27/05/2026",
"tipo": "01",
"serie": "F002",
"numero": "52385",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C. MONEDA: SOLES",
"total_factura": "20.00",
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": "27.00",
"lineas": [
{
"descripcion": "SERVICIO DE TRANSPORTE UND SERVICIO DE TRANSPORTE 380 BL CEMENTO S/ 1.3396 P. PARTIDA : AV. ATOCONGO 1860 LIMA-LIMA-VILLA MARIA DEL TRIUNFO P. LLEGADA : JR.LOMA DE LOS PENSAMIENTOS MZ.W4 LT.7 URB. PROLONG. BENAVIDES -LIMA-LIMA SEGÚN GRR : T001-0129437 SEGÚN GRT : V001-00004156 VALOR REFERENCIAL : S/ 509.05 CONFIG. VEHICULAR : C3 CARGA TM. : 16.150 NO REGISTRO M.T.C. : 1525019 CNG NRO. DE PLACA : A1Y-872",
"cantidad": "1.0000",
"precio_unitario": "0.00",
"total_linea": "509.05",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129437"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V001",
"numero": "4156"
}
}
],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/008_correo/20154645005-01-F002-52385.pdf"
},
{
"ruc_emisor": "20154645005",
"razon_social_emisor": null,
"fecha_emision": "05/33/2026",
"tipo": "31",
"serie": "2605",
"numero": "33",
"ruc_receptor": "20100029741",
"razon_social_receptor": null,
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/008_correo/20154645005-31-2605-33.pdf"
}
]
@@ -0,0 +1,94 @@
[
{
"ruc_emisor": "20154645005",
"razon_social_emisor": "DISTRIBUIDORA SANTA PATRICIA S R L",
"fecha_emision": "2026-05-27",
"tipo": "01",
"serie": "F002",
"numero": "52385",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 509.05,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERVICIO DE TRANSPORTE",
"cantidad": 1.0,
"precio_unitario": 509.05,
"total_linea": 431.4,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129437"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "V001",
"numero": "4156"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/008_correo/20154645005-01-F002-52385.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "20154645005",
"razon_social_emisor": "DISTRIBUIDORA SANTA PATRICIA SRL",
"fecha_emision": "2026-05-25",
"tipo": "31",
"serie": "V001",
"numero": "4156",
"ruc_receptor": "20154645005",
"razon_social_receptor": "DISTRIBUIDORA SANTA PATRICIA S R L",
"total_factura": null,
"documentos_referencia": [],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860",
"punto_llegada": "JR. LOMA DE LOS PENSAMIENTOS MZ. W4 LT. 7 URB. PROL. BENAVIDES",
"peso_bruto": "16150",
"unidad_peso_bruto": "KGM",
"fecha_traslado": "2026-05-25",
"vehiculo_principal": "A1Y872",
"vehiculo_secundario": null,
"licencia_conductor": "Q42833005",
"nombre_conductor": "RUBEN",
"documento_conductor": "42833005",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO APU",
"cantidad": 380.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/008_correo/20154645005-31-V001-4156.xml",
"ruta_pdf": null
}
]
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="NubeFacTSign">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>G7SzTRDnTkSZdOyu+9zgFKHwWnx55GfjPVWrIV1uLx4=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AjOMqnf60Ht+zfmOSy8RcWl2nzXAFSwAiLXKQ6UwumxeXiIuD0oBhRNTPv5AGXHUELJsFAozlPNQOm/NNMvALdTa0JG3OCJuhCD8Bj4CanCfgF4ucl5QbwjZaZDSqeKCseE9iHcOdi/oOSdeI2r6d3OaO1wu4tRNM+DeagQep3RscEHNsLIY5TtXcfHXYXTDmEYywfR6sBWlHGLc+OnzbCrgB3/oa6ZbWUmdMDaRcfa82efT1GFpRJ+x6naq/7WxfheGnyLx/jv1NUx5gleaBKboT2eFTb/bsedGWFhnRFO0IySk0VbsdZlSB0BkCbuADDA7gJ7KpRSSxOk/36YSnA==</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>street=CAL. LIBERTAD NRO. 176 INT. 211,emailAddress=sistemas@nubefact.com,CN=NUBEFACT WEB,OU=20600695771,OU=Validado por Llama.pe ER,O=NUBEFACT S.A.,L=LIMA,C=PE</ds:X509SubjectName>
<ds:X509Certificate>MIIFLzCCBBegAwIBAgIIeqYdzau699YwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMzA1MTgzODI1WhcNMjkwMzA0MTgzODAwWjCB1DEoMCYGA1UECQwfQ0FMLiBMSUJFUlRBRCBOUk8uIDE3NiBJTlQuIDIxMTEkMCIGCSqGSIb3DQEJARYVc2lzdGVtYXNAbnViZWZhY3QuY29tMRUwEwYDVQQDDAxOVUJFRkFDVCBXRUIxFDASBgNVBAsMCzIwNjAwNjk1NzcxMSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxFjAUBgNVBAoMDU5VQkVGQUNUIFMuQS4xDTALBgNVBAcMBExJTUExCzAJBgNVBAYTAlBFMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3PnivJidKntheakfk5pA2SOXyG66a1FQtSW1BtXMpN2C953XT9iJLbR9kD/2hUsTs08drxdkSeKJwyMvUL41VpGX54PkSBXg1nqwbNN9dwj+zSd1PsFcmLtpEizyU4r6B8PSo07cdAzisAhnt3F1X/LcpcuJcUT3x88RJt18vSbh903Ihvp6BV7BCkJk/MJSRKeJnVMYJPtrgLvTxH03DUs6mIozR673WmYZAdzXPo1Kv0JpfrP/XTcaZECe2VQ+l7OEQS/s2bSjL7K8VgvTW+PtkCGZu9q6UUYsr3F1UL7TVad9zEFgL7rMQvELT7r+YWCP4AwVfTrXvqAoMednKQIDAQABo4IBkDCCAYwwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBRdiFut62X7/mii5NlvPVdyou8rmTBnBggrBgEFBQcBAQRbMFkwNQYIKwYBBQUHMAKGKWh0dHA6Ly9jcnQubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5sbGFtYS5wZTAgBgNVHREEGTAXgRVzaXN0ZW1hc0BudWJlZmFjdC5jb20wRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY3JsMB0GA1UdDgQWBBSZK2idJe/W7w+eTwXNhJ/o8XK6pjAOBgNVHQ8BAf8EBAMCBsAwDQYJKoZIhvcNAQELBQADggEBAIEF6UtQGQ+yLKVVRCdiogInGUGv7IECqg9gMQNZBZ2ZP9SgQOOTFIsDI0psbS3D6u/wJ2vgf78a287ysqJUcGqhW1+plQh4toN3v8bCqwXACTIYW6bUU5bOab0QY//xrLkNZ8Iwa/M/LZstU3dgZjIwXtcMD+ANAIynN4rcNNgeVja8lnbYU3WQ1u9UIjMxtSp1rLf4mwTGSeLI1uuAWgWczkMaFjMF1RJhX9VlKX8qDC+pr/jiu8LTHt8VDeVvDwpZ4qVbI/8qq+VUyXjBffcDdeY5qb75nnlhEMHLey3t/GcEo80nmtjTKmBdroVkooIat7PcSvfQQ+kzrSnV2as=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
</ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>FPP1-15407</cbc:ID>
<cbc:IssueDate>2026-05-29</cbc:IssueDate>
<cbc:IssueTime>16:32:15</cbc:IssueTime>
<cbc:InvoiceTypeCode listID="1001" listAgencyName="PE:SUNAT" listName="Tipo de Documento" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo01" name="Tipo de Operacion" listSchemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo51">01</cbc:InvoiceTypeCode>
<cbc:Note languageLocaleID="1000">SEISCIENTOS NOVENTA Y SEIS CON 59/100 SOLES</cbc:Note>
<cbc:Note languageLocaleID="2006">Operacion sujeta a detraccion</cbc:Note>
<cbc:DocumentCurrencyCode listID="ISO 4217 Alpha" listAgencyName="United Nations Economic Commission for Europe" listName="Currency">PEN</cbc:DocumentCurrencyCode>
<cac:Signature>
<cbc:ID>10091153136</cbc:ID>
<cbc:Note>WWW.NUBEFACT.COM</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>ZAPATA ARAPA EUSEBIO</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>10091153136</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>FERRETERÍA DEPOSITO 1° DE MAYO</cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ZAPATA ARAPA EUSEBIO</cbc:RegistrationName>
<cac:RegistrationAddress>
<cbc:ID schemeName="Ubigeos" schemeAgencyName="PE:INEI">150142</cbc:ID>
<cbc:AddressTypeCode listAgencyName="PE:SUNAT" listName="Establecimientos anexos">0000</cbc:AddressTypeCode>
<cbc:CityName>LIMA</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>VILLA SALVADOR</cbc:District>
<cac:AddressLine>
<cbc:Line>Av. 1 DE MAYO MZ C LT 17 ADEICOSUR, PARQUE INDUSTRIAL</cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode listID="ISO 3166-1" listAgencyName="United Nations Economic Commission for Europe" listName="Country">PE</cbc:IdentificationCode>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>A. BERIO Y CIA S.A.C.</cbc:RegistrationName>
<cac:RegistrationAddress>
<cac:AddressLine>
<cbc:Line>AV. 26 DE NOVIEMBRE NRO. 2473 A.H. VIRGEN DE LOURDES - LIMA LIMA VILLA MARIA DEL TRIUNFO</cbc:Line>
</cac:AddressLine>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:ID>Detraccion</cbc:ID>
<cbc:PaymentMeansCode listName="Medio de pago" listAgencyName="PE:SUNAT" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo59">001</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>0000000000</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:ID>Detraccion</cbc:ID>
<cbc:PaymentMeansID schemeName="Codigo de detraccion" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo54">027</cbc:PaymentMeansID>
<cbc:PaymentPercent>4.00</cbc:PaymentPercent>
<cbc:Amount currencyID="PEN">28.00</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Credito</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">668.59</cbc:Amount>
</cac:PaymentTerms>
<cac:PaymentTerms>
<cbc:ID>FormaPago</cbc:ID>
<cbc:PaymentMeansID>Cuota001</cbc:PaymentMeansID>
<cbc:Amount currencyID="PEN">668.59</cbc:Amount>
<cbc:PaymentDueDate>2026-06-29</cbc:PaymentDueDate>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">106.26</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">590.33</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">106.26</cbc:TaxAmount>
<cac:TaxCategory>
<cac:TaxScheme>
<cbc:ID schemeName="Codigo de tributos" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo05">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="PEN">590.33</cbc:LineExtensionAmount>
<cbc:TaxInclusiveAmount currencyID="PEN">696.59</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="PEN">0.00</cbc:AllowanceTotalAmount>
<cbc:ChargeTotalAmount currencyID="PEN">0.00</cbc:ChargeTotalAmount>
<cbc:PrepaidAmount currencyID="PEN">0.00</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="PEN">696.59</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="ZZ" unitCodeListID="UN/ECE rec 20" unitCodeListAgencyName="United Nations Economic Commission for Europe">520.0</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="PEN">590.33</cbc:LineExtensionAmount>
<cac:PricingReference>
<cac:AlternativeConditionPrice>
<cbc:PriceAmount currencyID="PEN">1.3396</cbc:PriceAmount>
<cbc:PriceTypeCode listAgencyName="PE:SUNAT" listName="Tipo de Precio" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo16">01</cbc:PriceTypeCode>
</cac:AlternativeConditionPrice>
</cac:PricingReference>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="PEN">106.26</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="PEN">590.33</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="PEN">106.26</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:Percent>18.00</cbc:Percent>
<cbc:TaxExemptionReasonCode listAgencyName="PE:SUNAT" listName="Afectacion del IGV" listURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo07">10</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID schemeName="Codigo de tributos" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo05">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description>SERVICIO DE TRANSPORTE 520 CEMENTO PTO DE PDA. AV. ATOCONGO 1860 VMT- LIMA PRO DE LLDA. AV. PRIMERO DE MAYO MZA. C LT17 OTRO. PARCELA 1 – ADEICOSUR- VILLA EL SALVADOR – LIMA – LIMA. ADEICOSUR - VES SEGÚN GRR. T001- 129749 SEGÚN GRT. VPP1 – 516 R.E.G.M.T.C. Nº 1516491 C.N.G. C.V. T2S2 CARGA. 22.10 V.R. 696.592 CONCEPTO: 027 – TRANSPORTE DE BIENES PORCENTAJE: 4% MONTO DETRACC.: S/ 28.00 NETO A COBRAR: S/ 668.592 OPERACIÓN SUJETA AL SPOT CON EL GOBIERNO CENTRAL CTA BCO NACIÓN 00-063-049697</cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>001</cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="PEN">1.1352542373</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
</Invoice>
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<DespatchAdvice xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2">
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="NubeFacTSign">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>pGFus9mok5BsHOvwP9jXVzznwtgwSX8F9LIyJYnikxk=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>jJovOUNvc7dTBurWODbsH4GaeGH3N7bFZkPjfHCS3BSNiQH4YR6ykNGLAzZY8M2SgP+y/RNHrUI3ZMfo+WQ6dXjXJYiC95lnYY3zf+KKT9XI/nsPztOUCtm8yyCO5W2zuZQkh9WQRJCg0G8TKwd7mf/gZ5xWurVWSzlMo9tyz+gRIyFsnt+nvSJ0Lx8d0VduUrNVsUBG31ruSdAavU0Dm5r86njdnlPLVnXVy42FMjlCAmgNEedtkFBQ0+ipCfTuCis8gUv5XI6GPBrCuwR3c1nO9Bmh+zVuijc4J1R6jLuPvRT96iMykyB1UXatK0isFcHG209moHUlpfn+C0OEoQ==</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>street=CAL. LIBERTAD NRO. 176 INT. 211,emailAddress=sistemas@nubefact.com,CN=NUBEFACT WEB,OU=20600695771,OU=Validado por Llama.pe ER,O=NUBEFACT S.A.,L=LIMA,C=PE</ds:X509SubjectName>
<ds:X509Certificate>MIIFLzCCBBegAwIBAgIIeqYdzau699YwDQYJKoZIhvcNAQELBQAwRjEkMCIGA1UEAwwbTGxhbWEucGUgU0hBMjU2IFN0YW5kYXJkIENBMREwDwYDVQQKDAhMTEFNQS5QRTELMAkGA1UEBhMCUEUwHhcNMjYwMzA1MTgzODI1WhcNMjkwMzA0MTgzODAwWjCB1DEoMCYGA1UECQwfQ0FMLiBMSUJFUlRBRCBOUk8uIDE3NiBJTlQuIDIxMTEkMCIGCSqGSIb3DQEJARYVc2lzdGVtYXNAbnViZWZhY3QuY29tMRUwEwYDVQQDDAxOVUJFRkFDVCBXRUIxFDASBgNVBAsMCzIwNjAwNjk1NzcxMSEwHwYDVQQLDBhWYWxpZGFkbyBwb3IgTGxhbWEucGUgRVIxFjAUBgNVBAoMDU5VQkVGQUNUIFMuQS4xDTALBgNVBAcMBExJTUExCzAJBgNVBAYTAlBFMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3PnivJidKntheakfk5pA2SOXyG66a1FQtSW1BtXMpN2C953XT9iJLbR9kD/2hUsTs08drxdkSeKJwyMvUL41VpGX54PkSBXg1nqwbNN9dwj+zSd1PsFcmLtpEizyU4r6B8PSo07cdAzisAhnt3F1X/LcpcuJcUT3x88RJt18vSbh903Ihvp6BV7BCkJk/MJSRKeJnVMYJPtrgLvTxH03DUs6mIozR673WmYZAdzXPo1Kv0JpfrP/XTcaZECe2VQ+l7OEQS/s2bSjL7K8VgvTW+PtkCGZu9q6UUYsr3F1UL7TVad9zEFgL7rMQvELT7r+YWCP4AwVfTrXvqAoMednKQIDAQABo4IBkDCCAYwwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBRdiFut62X7/mii5NlvPVdyou8rmTBnBggrBgEFBQcBAQRbMFkwNQYIKwYBBQUHMAKGKWh0dHA6Ly9jcnQubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5sbGFtYS5wZTAgBgNVHREEGTAXgRVzaXN0ZW1hc0BudWJlZmFjdC5jb20wRgYDVR0gBD8wPTA7Bg0rBgEEAYOXdwABAAMBMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vbGxhbWEucGUvcmVwb3NpdG9yeS8wHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwubGxhbWEucGUvbGxhbWFwZXN0YW5kYXJkY2EuY3JsMB0GA1UdDgQWBBSZK2idJe/W7w+eTwXNhJ/o8XK6pjAOBgNVHQ8BAf8EBAMCBsAwDQYJKoZIhvcNAQELBQADggEBAIEF6UtQGQ+yLKVVRCdiogInGUGv7IECqg9gMQNZBZ2ZP9SgQOOTFIsDI0psbS3D6u/wJ2vgf78a287ysqJUcGqhW1+plQh4toN3v8bCqwXACTIYW6bUU5bOab0QY//xrLkNZ8Iwa/M/LZstU3dgZjIwXtcMD+ANAIynN4rcNNgeVja8lnbYU3WQ1u9UIjMxtSp1rLf4mwTGSeLI1uuAWgWczkMaFjMF1RJhX9VlKX8qDC+pr/jiu8LTHt8VDeVvDwpZ4qVbI/8qq+VUyXjBffcDdeY5qb75nnlhEMHLey3t/GcEo80nmtjTKmBdroVkooIat7PcSvfQQ+kzrSnV2as=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
</ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>VPP1-516</cbc:ID>
<cbc:IssueDate>2026-05-29</cbc:IssueDate>
<cbc:IssueTime>16:28:14</cbc:IssueTime>
<cbc:DespatchAdviceTypeCode>31</cbc:DespatchAdviceTypeCode>
<cbc:Note>Obs: MARCA VEHICULO: VOLVO N CONF.VEHICULAR: T2 S2</cbc:Note>
<cac:AdditionalDocumentReference>
<cbc:ID>T001-129749</cbc:ID>
<cbc:DocumentTypeCode>09</cbc:DocumentTypeCode>
<cbc:DocumentType>Guía de Remisión Remitente</cbc:DocumentType>
<cac:IssuerParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
</cac:IssuerParty>
</cac:AdditionalDocumentReference>
<cac:Signature>
<cbc:ID>10091153136</cbc:ID>
<cbc:Note>WWW.NUBEFACT.COM</cbc:Note>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>ZAPATA ARAPA EUSEBIO</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>10091153136</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cac:DespatchSupplierParty>
<cbc:CustomerAssignedAccountID schemeID="6">10091153136</cbc:CustomerAssignedAccountID>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ZAPATA ARAPA EUSEBIO</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DespatchSupplierParty>
<cac:DeliveryCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">10091153136</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ZAPATA ARAPA EUSEBIO</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:DeliveryCustomerParty>
<cac:Shipment>
<cbc:ID>SUNAT_Envio</cbc:ID>
<cbc:GrossWeightMeasure unitCode="TNE">22.1</cbc:GrossWeightMeasure>
<cac:ShipmentStage>
<cac:TransitPeriod>
<cbc:StartDate>2026-05-29</cbc:StartDate>
</cac:TransitPeriod>
<cac:CarrierParty>
<cac:PartyLegalEntity>
<cbc:CompanyID>1516491CNG</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:CarrierParty>
<cac:TransportMeans>
<cac:RoadTransport>
<cbc:LicensePlateID>ABB758</cbc:LicensePlateID>
</cac:RoadTransport>
</cac:TransportMeans>
<cac:DriverPerson>
<cbc:ID schemeID="1">42327525</cbc:ID>
<cbc:FirstName>LUIS AUGUSTO</cbc:FirstName>
<cbc:FamilyName>YARANGA MARTINEZ</cbc:FamilyName>
<cbc:JobTitle>Principal</cbc:JobTitle>
<cac:IdentityDocumentReference>
<cbc:ID>Q42327525</cbc:ID>
</cac:IdentityDocumentReference>
</cac:DriverPerson>
</cac:ShipmentStage>
<cac:Delivery>
<cac:DeliveryAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150142</cbc:ID>
<cac:AddressLine>
<cbc:Line>PQ. INDUSTRIAL-AV. 1 DE MAYO MZ. C LT. 17- ADEICOSUR- PARC. 1 -VILLA EL SALVADOR - LIMA - LIMA</cbc:Line>
</cac:AddressLine>
</cac:DeliveryAddress>
<cac:Despatch>
<cac:DespatchAddress>
<cbc:ID schemeAgencyName="PE:INEI" schemeName="Ubigeos">150143</cbc:ID>
<cac:AddressLine>
<cbc:Line>AV. ATOCONGO 1860 VILLA MARIA DE L TRIUNFO</cbc:Line>
</cac:AddressLine>
</cac:DespatchAddress>
<cac:DespatchParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6" schemeName="Documento de Identidad" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo06">20100029741</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>A. BERIO Y CIA S.A.C.</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:DespatchParty>
</cac:Despatch>
</cac:Delivery>
<cac:TransportHandlingUnit>
<cac:TransportEquipment>
<cbc:ID>ABB758</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>0151601857</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
<cac:AttachedTransportEquipment>
<cbc:ID>A6T980</cbc:ID>
<cac:ApplicableTransportMeans>
<cbc:RegistrationNationalityID>0151834688</cbc:RegistrationNationalityID>
</cac:ApplicableTransportMeans>
</cac:AttachedTransportEquipment>
</cac:TransportEquipment>
</cac:TransportHandlingUnit>
</cac:Shipment>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
<cac:DespatchLine>
<cbc:ID>1</cbc:ID>
<cbc:DeliveredQuantity unitCode="NIU" unitCodeListID="UN/ECE rec 20" unitCodeListAgencyName="United Nations Economic Commission for Europe">520.0</cbc:DeliveredQuantity>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>CEMENTO SOL 42.50KG</cbc:Description>
<cac:SellersItemIdentification>
<cbc:ID>CEM01</cbc:ID>
</cac:SellersItemIdentification>
</cac:Item>
</cac:DespatchLine>
<!-- GENERADO DESDE WWW.NUBEFACT.COM -->
</DespatchAdvice>
@@ -0,0 +1,20 @@
[
{
"ruc_emisor": "10091153136",
"razon_social_emisor": null,
"fecha_emision": "29/05/2026",
"tipo": "01",
"serie": "FPP1",
"numero": "15407",
"ruc_receptor": "20100029741",
"razon_social_receptor": null,
"total_factura": "696.59",
"documentos_referencia": [],
"tiene_detraccion": true,
"codigo_detraccion": null,
"porcentaje_detraccion": "4",
"monto_detraccion": "28.00",
"lineas": [],
"ruta_pdf": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/009_correo/10091153136-01-FPP1-15407.pdf"
}
]
@@ -0,0 +1,111 @@
[
{
"ruc_emisor": "10091153136",
"razon_social_emisor": "FERRETERÍA DEPOSITO 1° DE MAYO",
"fecha_emision": "2026-05-29",
"tipo": "01",
"serie": "FPP1",
"numero": "15407",
"ruc_receptor": "20100029741",
"razon_social_receptor": "A. BERIO Y CIA S.A.C.",
"total_factura": 696.59,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129749"
},
{
"tipo": "31",
"serie": "VPP1",
"numero": "516"
}
],
"tiene_detraccion": true,
"codigo_detraccion": "027",
"porcentaje_detraccion": 4.0,
"monto_detraccion": 28.0,
"punto_partida": null,
"punto_llegada": null,
"peso_bruto": null,
"unidad_peso_bruto": null,
"fecha_traslado": null,
"vehiculo_principal": null,
"vehiculo_secundario": null,
"licencia_conductor": null,
"nombre_conductor": null,
"documento_conductor": null,
"ubigeo_partida": null,
"lineas": [
{
"descripcion": "SERVICIO DE TRANSPORTE 520 CEMENTO PTO DE PDA. AV. ATOCONGO 1860 VMT- LIMA PRO DE LLDA. AV. PRIMERO DE MAYO MZA. C LT17 OTRO. PARCELA 1 – ADEICOSUR- VILLA EL SALVADOR – LIMA – LIMA. ADEICOSUR - VES SEGÚN GRR. T001- 129749 SEGÚN GRT. VPP1 – 516 R.E.G.M.T.C. Nº 1516491 C.N.G. C.V. T2S2 CARGA. 22.10 V.R. 696.592 CONCEPTO: 027 – TRANSPORTE DE BIENES PORCENTAJE: 4% MONTO DETRACC.: S/ 28.00 NETO A COBRAR: S/ 668.592 OPERACIÓN SUJETA AL SPOT CON EL GOBIERNO CENTRAL CTA BCO NACIÓN 00-063-049697",
"cantidad": 520.0,
"precio_unitario": 1.3396,
"total_linea": 590.33,
"notes_text": "",
"guias_remision": [
{
"tipo": "09",
"serie": "T001",
"numero": "129749"
}
],
"guia_transportista": {
"tipo": "31",
"serie": "VPP1",
"numero": "516"
}
}
],
"tipo_descripcion": "Factura",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/009_correo/10091153136-01-FPP1-15407.xml",
"ruta_pdf": null
},
{
"ruc_emisor": "10091153136",
"razon_social_emisor": "ZAPATA ARAPA EUSEBIO",
"fecha_emision": "2026-05-29",
"tipo": "31",
"serie": "VPP1",
"numero": "516",
"ruc_receptor": "10091153136",
"razon_social_receptor": "ZAPATA ARAPA EUSEBIO",
"total_factura": null,
"documentos_referencia": [
{
"tipo": "09",
"serie": "T001",
"numero": "129749"
}
],
"tiene_detraccion": false,
"codigo_detraccion": null,
"porcentaje_detraccion": null,
"monto_detraccion": null,
"punto_partida": "AV. ATOCONGO 1860 VILLA MARIA DE L TRIUNFO",
"punto_llegada": "PQ. INDUSTRIAL-AV. 1 DE MAYO MZ. C LT. 17- ADEICOSUR- PARC. 1 -VILLA EL SALVADOR - LIMA - LIMA",
"peso_bruto": "22.1",
"unidad_peso_bruto": "TNE",
"fecha_traslado": "2026-05-29",
"vehiculo_principal": "ABB758",
"vehiculo_secundario": "A6T980",
"licencia_conductor": "Q42327525",
"nombre_conductor": "LUIS AUGUSTO",
"documento_conductor": "42327525",
"ubigeo_partida": "150143",
"lineas": [
{
"descripcion": "CEMENTO SOL 42.50KG",
"cantidad": 520.0,
"precio_unitario": 0.0,
"total_linea": 0.0,
"notes_text": "",
"guias_remision": [],
"guia_transportista": null
}
],
"tipo_descripcion": "Guía de Remisión Transportista",
"ruta_xml": "/Users/george/Documents/Desarrollo/Python/aberio/descarga_factura_transporte/tests/correos_prueba/009_correo/10091153136-31-VPP1-516.xml",
"ruta_pdf": null
}
]
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff. Show more