From d7c4d532b5c500721bb1c6a762b0e9a41d177041 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 3 Aug 2026 09:48:57 -0500 Subject: [PATCH] desarrollo instalado en el cliente globe italia --- .gitignore | 5 + data/html/test_email.html | 35 +++++ docs/flujo.md | 265 ++++++++++++++++++++++++++++++++ docs/logging_spec.md | 133 ++++++++++++++++ docs/parser_spec.md | 189 +++++++++++++++++++++++ email_reporter.py | 90 +++++++++++ logger_manager.py | 116 ++++++++++++++ mail_sender.py | 100 ++++++++++++ main.py | 109 +++++++++++++ requirements.txt | 5 + sapb1_manager.py | 207 +++++++++++++++++++++++++ sapb1_mapper.py | 77 ++++++++++ service.py | 313 ++++++++++++++++++++++++++++++++++++++ sql_manager.py | 110 ++++++++++++++ template.html | 143 +++++++++++++++++ test/b1.json | 32 ++++ test/sl.http | 17 +++ 17 files changed, 1946 insertions(+) create mode 100644 .gitignore create mode 100644 data/html/test_email.html create mode 100644 docs/flujo.md create mode 100644 docs/logging_spec.md create mode 100644 docs/parser_spec.md create mode 100755 email_reporter.py create mode 100755 logger_manager.py create mode 100755 mail_sender.py create mode 100755 main.py create mode 100755 requirements.txt create mode 100755 sapb1_manager.py create mode 100755 sapb1_mapper.py create mode 100755 service.py create mode 100755 sql_manager.py create mode 100755 template.html create mode 100644 test/b1.json create mode 100644 test/sl.http diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fc7e59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +logs/ +*.pyc +.env +.DS_Store diff --git a/data/html/test_email.html b/data/html/test_email.html new file mode 100644 index 0000000..0737c85 --- /dev/null +++ b/data/html/test_email.html @@ -0,0 +1,35 @@ + + + + +

Orden de Compra — 780790022025012792230

+ + + + +
N° OC:780790022025012792230
Fecha Entrega:2026-07-17
Cliente:C000003-327 — CLIENTE PRUEBA SA
+ + + + + + + + + + + + + + + + + + + + + + +
cod_upcempaquescolor_talla_umunid_empaquecantidad_unidImporteprecio_unidadItemCodePrecio SAPTextodescripción
17730976822923510CAJ/ 520GR15104,155,4808,148SUB-001$8,148OKPURE DE TOMATE CJ
17730976822381476CAJ/ 1030GR14767,202,83215,132SUB-002$16,000Precio difiere: $15,132 vs $16,000PURE DE TOMATE CJ
999999999999991010100500,0005,000——Sin catálogoITEM SIN CATALOGO
+ + \ No newline at end of file diff --git a/docs/flujo.md b/docs/flujo.md new file mode 100644 index 0000000..e388df2 --- /dev/null +++ b/docs/flujo.md @@ -0,0 +1,265 @@ +# Flujo del Proceso — ComercioNet + +## Visión General + +``` +[Emisor (Walmart, etc.)] + │ + │ Envía XML OC vía AS2 / SFTP + ▼ + ┌─────────────────┐ + │ data/input/ │ ← Carpeta de llegada + └────────┬────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────┐ + │ main.py │ + │ (servicio NSSM, loop con schedule) │ + │ │ + │ Lun–Vie 08:00–18:00 → cada 10 min │ + │ Lun–Vie 18:00–23:00 → cada 1 hora │ + │ Fuera de horario / finde → dormido │ + └────────┬─────────────────────────────────────────┘ + │ importa service.py + ▼ + ┌─────────────────────────────────────────────────-─┐ + │ service.py │ + │ │ + │ 1. Leer XML de data/input/ │ + │ 2. Parsear (strip ns → extraer campos) │ + │ 3. Calcular descuentos secuenciales │ + │ 4. Generar JSON intermedio → data/output/ │ + │ 5. Generar HTML visual → data/html/ │ + │ 6. SAP: resolver CardCode, catálogo, precios │ + │ 7. Generar email HTML → data/html/*_email.html │ + │ 8. Construir payload Draft │ + │ 9. Validar precios contra SAP (todas las líneas) │ + │ ├── Todo OK → POST /Drafts │ + │ └── Diferencias → log + skip Draft │ + │ 10. Mover XML a data/processed/ │ + └────────┬──────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ data/sap_b1/ │ ← payload Draft (*_sap.json) + │ (siempre se crea) │ + └─────────────────────┘ +``` + +## Archivos del sistema + +``` +comercio-net/ +├── main.py ← Entry point del servicio NSSM +├── logger_manager.py ← Configuración de logging rotativo diario +├── service.py ← Lógica: parseo, descuentos, JSON, HTML, SAP +├── sapb1_manager.py ← Conexión SAP B1 Service Layer (login, Draft) +├── sapb1_mapper.py ← Transforma JSON intermedio → payload Draft SAP +├── sql_manager.py ← Conexión SQL Server (catálogo OSCN, precios ITM1, CardName) +├── email_reporter.py ← Genera HTML para correo con validación de precios +├── template.html ← Plantilla HTML flat (Jinja2) +├── .env.dev ← Credenciales SAP B1 y SQL Server +├── requirements.txt ← Dependencias Python +├── install_service.bat ← Instalación como servicio Windows (NSSM) +│ +├── data/ +│ ├── input/ ← XML entrantes (el servicio vacía esta carpeta) +│ ├── processed/ ← XML ya procesados (backup) +│ ├── output/ ← JSON intermedio generado +│ ├── html/ ← HTML visual + HTML para correo (*_email.html) +│ └── sap_b1/ ← Payload Draft SAP (*_sap.json, siempre generado) +│ +├── logs/ ← Logs rotativos diarios (30 días de respaldo) +│ └── comercio-net.log +│ +└── docs/ + ├── parser_spec.md ← Especificación detallada del parseo XML + └── flujo.md ← Este documento +``` + +## Flujo detallado del servicio + +### 1. Inicio (`main.py`) + +``` +main() +├── signal.signal(SIGINT/SIGTERM) → _running = False +├── Loop infinito while _running: +│ ├── ¿Día hábil? (lun–vie) +│ │ └── No → dormir hasta próximo día hábil +│ ├── ¿Dentro de horario? +│ │ ├── 08:00–18:00 → intervalo = 600s (10 min) +│ │ ├── 18:00–23:00 → intervalo = 3600s (1 hora) +│ │ └── Fuera → dormir hasta las 08:00 +│ ├── Ejecutar process_all() +│ └── Dormir intervalo +``` + +### 2. Procesamiento (`service.py`) + +``` +process_all() +├── Leer archivos de data/input/ +├── Por cada archivo: +│ ├── process_file(xml_path) +│ │ ├── parse_order(xml_bytes) → dict (JSON intermedio) +│ │ ├── write_json(data, output/*.json) +│ │ ├── write_html(data, html/*.html) +│ │ ├── [SAP habilitado]: +│ │ │ ├── get_cardcode_by_gln(emisor, lugar) → CardCode +│ │ │ ├── get_item_catalog(cardcode) → {UPC: ItemCode} +│ │ │ ├── get_price_list(cardcode) → ListNum +│ │ │ ├── get_prices(ListNum) → {ItemCode: Price} +│ │ │ ├── get_card_name(cardcode) → CardName +│ │ │ ├── generate_email_html() → html/*_email.html +│ │ │ ├── to_draft_payload() → payload Draft +│ │ │ ├── write_json(payload, sap_b1/*_sap.json) +│ │ │ ├── Validar todas las líneas (precio XML vs SAP) +│ │ │ │ ├── Todas OK → create_draft(payload) +│ │ │ │ └── Diferencias → log + omitir Draft +│ │ │ └── (errores no bloquean, se loguean) +│ │ └── move a data/processed/ +│ └── Si error → log + continúa con siguiente +``` + +### 3. Parseo del XML (`service.py.parse_order`) + +``` +parse_order(xml_bytes) +├── _strip_ns(root) ← Elimina namespaces (soporta con/sin prefijo) +├── Extraer cabecera: +│ ├── Emisor / Receptor +│ ├── N° OC (uniqueCreatorIdentification) +│ ├── Fechas (creationDate, movementDate) +│ ├── Condiciones de pago (paymentTerms) +│ ├── Lugar de entrega (shipParty GLN) +│ ├── Dpto. ventas, Tipo OC, Promoción, Proveedor +│ └── Transporte (mainCarrierTransport) +├── Por cada lineItem: +│ ├── Precio, Cantidad (CS/CSC), Unid/empaque (SIN) +│ ├── GTIN, Item (buyer), Cód. Prov. (vendor) +│ ├── Color / Talla +│ ├── allowanceCharge (0..N) +│ ├── apply_allowances(netPrice, qty, allowances) → total calculado +│ └── precio_efectivo = total_calculado / qty +├── Calcular totales (suma de importes) +└── Devolver dict con metadata, cabecera, detalle, totales +``` + +### 4. Algoritmo de descuentos (`service.py.apply_allowances`) + +``` +base_total = netPrice × requestedQuantity + +Para cada allowanceCharge con percentage > 0: + descuento = redondear(base_total × % / 100) + base_total = base_total - descuento + +totalAmount = base_total +``` + +- Los descuentos se aplican **secuencialmente** sobre el saldo restante +- El redondeo es a entero (.5 hacia arriba) +- El resultado debe coincidir con `totalAmount` del XML + +## Formatos de salida + +### JSON intermedio (`data/output/*.json`) + +```json +{ + "metadata": { + "archivo": "925485K200...xml", + "procesado": "2026-07-21T10:00:00" + }, + "cabecera": { + "emisor": "925485K200", + "receptor": "7800AD0CL0001", + "numero_oc": "780790022025012792230", + "fecha_generacion": "2026-07-14T00:00:00", + "fecha_embarque": "2026-07-17", + "condiciones_pago": "NET 60 días desde fecha de factura", + "lugar_entrega": "7807900001686", + "dpto_ventas": "92", + "tipo_oc": "20", + "promocion": "POS REPLEN", + "numero_proveedor": "006205920", + "observaciones": "NO PRETICKET" + }, + "detalle": [ + { + "linea": "000001", + "cod_upc": "17730976822923", + "item": "000706071", + "cod_prov": "PONT2292", + "color_talla_um": "CAJ / 520GR", + "descripcion": "PURE DE TOMATE CJ", + "precio_original": 8148, + "moneda": "CLP", + "allowances": [], + "precio_efectivo": 8148, + "empaques": 510, + "unidad_empaque": "CSC", + "unid_empaque": 12, + "cantidad_unid": 6120, + "importe": 4155480 + } + ], + "totales": { + "importe_total": 31590312, + "moneda": "CLP" + } +} +``` + +### HTML visual (`data/html/*.html`) + +Renderiza la misma información en una tabla plana auto-contenida, sin dependencias externas. Usa Jinja2 con la plantilla `template.html`. + +### HTML para correo (`data/html/*_email.html`) + +Generado por `email_reporter.generate_email_html()`. Incluye: + +- **Cabecera**: N° OC, Fecha Entrega, Cliente (CardCode — CardName) +- **Tabla detalle**: cod_upc, empaques, color_talla_um, unid_empaque, cantidad_unid, Importe, precio_unidad, ItemCode, Precio SAP, Texto, descripción +- **Columna Texto**: indica si el ítem tiene catálogo, precio SAP, y si el precio XML difiere del SAP + +### Payload SAP Draft (`data/sap_b1/*_sap.json`) + +Generado por `sapb1_mapper.to_draft_payload()`. Se crea **siempre** (incluso si hay diferencias de precio). Solo se envía a SAP si todas las líneas pasan la validación de precios. + +## Módulos SAP + +| Módulo | Función | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sql_manager.py` | Conexión SQL Server vía `pymssql`. Queries a CRD1 (CardCode por GLN), OSCN (catálogo por BP), OCRD (lista precios), ITM1 (precios), OCRD (CardName) | +| `sapb1_mapper.py` | `to_draft_payload()` — transforma JSON intermedio + datos resueltos → payload Draft SAP B1 | +| `sapb1_manager.py` | `login()` + `create_draft()` — autenticación Service Layer y POST /Drafts | +| `email_reporter.py` | `generate_email_html()` — genera HTML para correo con tabla de validación | + +## Validación de precios + +Por cada línea del detalle se calcula `precio_xml = importe / cantidad_unid` y se compara con `Precio SAP` (desde ITM1): + +- **Sin catálogo**: el UPC no tiene Substitute en OSCN +- **Sin precio SAP**: el ItemCode no tiene precio en ITM1 +- **Precio difiere**: precio XML ≠ precio SAP +- **OK**: todo coincide + +Si **alguna** línea tiene diferencia, el Draft **no** se crea en SAP (pero el payload y el email HTML se generan igual). + +## Schedule del servicio + +| Días | Rato | Intervalo | +| ------- | ------------- | ---------- | +| Lun–Vie | 08:00 – 18:00 | 10 minutos | +| Lun–Vie | 18:00 – 23:00 | 1 hora | +| Lun–Vie | 23:00 – 08:00 | Dormido | +| Sáb–Dom | Todo el día | Dormido | + +## Notas + +- El nombre del archivo XML no determina el contenido; todos los datos se extraen del XML. +- El archivo se mueve a `processed/` solo después de generar exitosamente JSON, HTML visual, email HTML y payload SAP. +- Si un archivo falla (XML mal formado, sin elemento ``, etc.), se loguea el error y se continúa con el siguiente. +- Los errores de conexión SAP o SQL Server no bloquean el procesamiento de archivos posteriores. diff --git a/docs/logging_spec.md b/docs/logging_spec.md new file mode 100644 index 0000000..4f7f21c --- /dev/null +++ b/docs/logging_spec.md @@ -0,0 +1,133 @@ +# Especificación de Logging — ComercioNet + +## Objetivo + +Implementar un sistema de logging jerárquico con formato visual de árbol (`├──`, `└──`, `│`) que permita entender rápidamente el flujo de procesamiento de cada archivo y sus sub-operaciones (SAP B1, SQL, etc.). + +## API + +El módulo `logger_manager.py` expone una función única: + +```python +from logger_manager import get_logger + +log = get_logger() # devuelve una instancia de TaskLogger +``` + +### Métodos de `TaskLogger` + +| Método | Descripción | Prefijo visual | +|---|---|---| +| `info(msg, _last=False)` | Mensaje informativo | `├──` o `└──` | +| `warning(msg, _last=False)` | Advertencia | `├──` o `└──` | +| `error(msg, _last=False)` | Error | `├──` o `└──` | +| `task(name, emoji="")` | Context manager — abre un bloque anidado (push depth) | `├── + emoji + name` | +| `last_task(name, emoji="")` | Context manager — último bloque del padre (push depth) | `└── + emoji + name` | + +### Parámetro `_last` + +En `info()`, `warning()`, `error()`, el parámetro `_last=True` cambia el prefijo de `├──` a `└──`, indicando que es el último mensaje en ese nivel. + +```python +log.info("Primer paso") # ├── Primer paso +log.info("Paso intermedio") # ├── Paso intermedio +log.info("Último paso", _last=True) # └── Último paso +``` + +## Estructura jerárquica con `task()` / `last_task()` + +```python +with log.task("📦 Archivo X.xml"): + log.info("XML parseado") + with log.task("🔐 SAP B1"): + log.info("Sesión iniciada") + log.info("Catálogo OK") + log.last_task("📁 Movido a processed") +``` + +### Salida generada + +``` +2026-07-31 10:15:03 ─── 📦 Archivo X.xml + ├── XML parseado + ├── 🔐 SAP B1 + │ ├── Sesión iniciada + │ └── Catálogo OK + └── 📁 Movido a processed +``` + +## Formato de cada línea + +``` +{timestamp} ─── {prefijo_árbol}{mensaje} +``` + +Ejemplo: +``` +2026-07-31 10:15:03 ─── │ ├── XML parseado +``` + +- `timestamp`: `YYYY-MM-DD HH:mm:ss` (precisión de segundos) +- `───`: separador fijo +- `prefijo_árbol`: caracteres de indentación y árbol según profundidad +- `mensaje`: texto del log + +## Formato en archivo de log + +El archivo `logs/comercio-net.log` usa el mismo formato con caracteres de árbol. No se incluyen niveles (INFO, WARNING, ERROR) en el texto — la diferenciación visual se logra con emojis y el contexto jerárquico. + +## Emojis por etapa + +| Etapa | Emoji | +|---|---| +| Procesar archivo XML | 📦 | +| Parseo XML / datos extraídos | 📄 | +| Guardar JSON / HTML | 💾 | +| SAP B1 (sesión, catálogo, draft) | 🔐 | +| Consultas HTTP (GET / POST) | 🔄 | +| Éxito / Draft creado | ✅ | +| Advertencia (precio difiere) | ⚠️ | +| Error | ❌ | +| Archivo movido | 📁 | +| Inicio / fin de servicio | 🚀 / 🛑 | + +## Configuración vía entorno + +Variable `LOG_LEVEL` en `.env` (valores: `DEBUG`, `INFO`, `WARNING`, `ERROR`). Por defecto `INFO`. + +``` +LOG_LEVEL=DEBUG +``` + +## Thread-safety + +Se usa `contextvars` para mantener el stack de profundidad por cada hilo / contexto asíncrono, permitiendo ejecución concurrente sin mezclar indentaciones. + +## Archivos modificados + +| Archivo | Cambio | +|---|---| +| `logger_manager.py` | Nuevas clases `TaskLogger`, `TreeFormatter`, `_TaskContext`. `get_logger()` devuelve `TaskLogger`. | +| `service.py` | Cambiar `logging.getLogger()` por `get_logger()`. Envolver `process_file()` y bloques SAP con `task()`. | +| `sapb1_manager.py` | Reemplazar `print()` por `log.info()/warning()/error()`. | +| `sql_manager.py` | Reemplazar `print()` por `log.error()`. | +| `main.py` | Ajustes menores para usar `task()` en el ciclo principal. | +| (nuevo) `.gitignore` | Excluir `logs/`, `__pycache__/`, `*.pyc`, `.env`, `.DS_Store`. | + +## Ejemplo completo de salida + +``` +2026-07-31 10:15:03 ─── 🚀 Servicio ComercioNet - Procesador OC +2026-07-31 10:15:03 ─── 📦 OC98765.xml + ├── 📄 XML parseado (orden E001-2439) + ├── 💾 JSON guardado en data/output/OC98765.json + ├── 💾 HTML generado + ├── 🔐 SAP B1 + │ ├── Sesión iniciada (B1SESSION: a634d0ec...) + │ ├── 🔄 [GET] CardCode por GLN + │ ├── Catálogo: 1520 items + │ ├── Precios: 980 registros + │ ├── ✅ Draft creado → DocEntry 2022 + │ └── 💾 Payload guardado + └── 📁 XML movido a processed/ +``` \ No newline at end of file diff --git a/docs/parser_spec.md b/docs/parser_spec.md new file mode 100644 index 0000000..e5ab293 --- /dev/null +++ b/docs/parser_spec.md @@ -0,0 +1,189 @@ +# Especificación de Parseo — Órdenes de Compra CommerceNet Chile + +## Formato del archivo + +- XML con esquema EAN.UCC (GS1) basado en `As2Envelope.xsd` +- Codificación: ISO-8859-1 +- Namespace: `http://www.uc-council.org/smp/schemas/eanucc` + +## Estructura del XML + +``` +envelope +├── messageHeader +│ ├── creationDate +│ ├── userId +│ ├── messageIdentifier +│ ├── to/gln +│ ├── from/gln +│ └── representingParty/gln +└── body + └── transaction + └── command + └── documentCommand + ├── documentCommandHeader (@type="ADD") + └── documentCommandOperand + └── order + ├── @creationDate + ├── @documentStatus + ├── paymentTerms + │ ├── @type + │ ├── @eventType + │ └── netPayment/timePeriodDue (@type, value) + ├── movementDate + ├── movementDateType + ├── typedEntityIdentification (@entityType="ORDER") + │ └── entityIdentification + │ ├── uniqueCreatorIdentification ← N° OC + │ └── contentOwner/gln + ├── specialHandling + ├── salesDepartamentNumber + ├── orderType + ├── promotionDealNumber + ├── internalVendorNumber + ├── buyer/gln + ├── seller/gln + ├── shipParty (@identificationType="SHIP_TO")/gln + ├── shipFrom/name + ├── currency + ├── mainCarrierTransport + │ ├── mode + │ ├── means + │ └── carrierName + └── lineItem (@number) + ├── netPrice/amount (@currencyISOcode) + ├── PriceType + ├── requestedQuantity (@UnitType) + ├── containedUnits (@UnitType) + ├── allowanceCharge (0..N) + │ ├── @allowanceOrChargeType ("ALLOWANCE" | "CHARGE") + │ └── monetaryAmountOrPercentage + │ ├── percentage + │ └── amount (@currencyISOcode) + ├── itemIdentification + │ ├── gtin + │ ├── buyerItemNumber (opcional) + │ └── vendorItemNumber (opcional) + ├── itemColor (opcional) + ├── itemSize (opcional) + ├── itemDescription (@language)/text + └── totalAmount/amount (@currencyISOcode) +``` + +## Reglas generales + +- Todos los valores provienen **exclusivamente** del contenido XML +- El nombre del archivo es solo referencial +- No inventar campos que no existan en el XML +- No recortar ni modificar valores + +## Mapeo Cabecera + +| Columna | Ruta XPath / Campo | Notas | +|---|---|---| +| Emisor | `envelope/messageHeader/from/gln` | | +| Receptor | `envelope/messageHeader/to/gln` | | +| N° Orden de Compra | `envelope/body/transaction/command/documentCommand/documentCommandOperand/order/typedEntityIdentification[@entityType='ORDER']/entityIdentification/uniqueCreatorIdentification` | Valor completo, sin recortes | +| Fecha generación mensaje | `envelope/body/transaction/command/documentCommand/documentCommandOperand/order/@creationDate` | Atributo creationDate del elemento order | +| Fecha de embarque | `envelope/body/transaction/command/documentCommand/documentCommandOperand/order/movementDate` | movementDate con movementDateType REQUESTED_DELIVERY | +| Fecha de cancelación | — | No existe en el XML, se deja vacío | +| Condiciones de pago | `paymentTerms/netPayment/timePeriodDue` | Formato: "NET {días} días desde fecha de factura" | +| Lugar de entrega | `shipParty[@identificationType='SHIP_TO']/gln` | | +| Dpto. ventas | `salesDepartamentNumber` | | +| Tipo OC | `orderType` | | +| Promoción | `promotionDealNumber` | | +| N° Proveedor | `internalVendorNumber` | | +| Observaciones | `specialHandling` | | + +## Mapeo Detalle (por línea) + +Orden de columnas: + +| # | Columna | Ruta XPath | Notas | +|---|---|---|---| +| 1 | Línea | `lineItem/@number` | | +| 2 | Cód. UPC (GTIN) | `lineItem/itemIdentification/gtin` | | +| 3 | Item (buyer) | `lineItem/itemIdentification/buyerItemNumber` | | +| 4 | Cód. Prov. | `lineItem/itemIdentification/vendorItemNumber` | | +| 5 | Color/Talla/UM | `itemColor` + `itemSize` | Concatenar como "Color / Talla" según lo que traiga cada producto | +| 6 | Descripción | `itemDescription[@language='SP']/text` | Primer elemento | +| 7 | Precio Unit. | `netPrice/amount` | Moneda del atributo @currencyISOcode | +| 8 | Empaques | `requestedQuantity` | Cantidad solicitada en unidades de empaque (CSC = cajas) | +| 9 | Unid/Empaque | `containedUnits` | Unidades contenidas por empaque (SIN) | +| 10 | Cantidad (unid) | — | **Derivado**: Empaques × Unid/Empaque | +| 11 | Importe | `totalAmount/amount` | Moneda del atributo @currencyISOcode | + +### Notas sobre UM + +- `requestedQuantity/@UnitType` = CSC o CS (Case) → Empaques +- `containedUnits/@UnitType` = SIN (Unit) → Unid/Empaque +- Los campos `itemColor` e `itemSize` son **opcionales** según el emisor. Si no existen, la columna "Color/Talla/UM" se deja vacía. + +### Algoritmo de Descuentos (allowanceCharge) + +Cada línea puede tener 0 o más bloques `allowanceCharge`. Solo se procesan los de tipo `ALLOWANCE` con `percentage > 0`. + +``` +base_total = netPrice × requestedQuantity + +para cada allowanceCharge con allowanceOrChargeType="ALLOWANCE" y percentage > 0: + descuento = round(base_total × percentage / 100) ← round a entero, .5 hacia arriba + base_total = base_total - descuento + +totalAmount (calculado) = base_total +``` + +**Reglas:** +- Los descuentos se aplican **secuencialmente** sobre el saldo restante (no todos sobre el original) +- El percentage y amount en el XML son por unidad, informativos. El cálculo real se hace sobre el total de la línea +- El resultado final debe coincidir con `lineItem/totalAmount/amount` +- Si hay múltiples ALLOWANCE, se encadenan: primero desc % sobre el total original, luego otro % sobre el resultado anterior + +**Ejemplos validados contra archivos reales:** + +| Lín | Precio | CS | Base | Allowances | Cálculo | totalAmount XML | +|---|---|---|---|---|---|---| +| 1 | 32.230 | 2 | 64.460 | 25% → round(16.115)=16.115; 30% → round(48.345×30%)=14.504 | 64.460−16.115−14.504 = **33.841** | 33.841 ✅ | +| 2 | 35.939 | 38 | 1.365.682 | 25% → round(341.420,5)=341.421; 15% → round(1.024.261×15%)=153.639 | 1.365.682−341.421−153.639 = **870.622** | 870.622 ✅ | +| 9 | 39.097 | 1 | 39.097 | 25% → round(9.774,25)=9.774 | 39.097−9.774 = **29.323** | 29.323 ✅ | +| 10 | 39.097 | 2 | 78.194 | 25% → round(19.548,5)=19.549 | 78.194−19.549 = **58.645** | 58.645 ✅ | +| 20 | 170.000 | 4 | 680.000 | 25% → 170.000; 20% → round(510.000×20%)=102.000 | 680.000−170.000−102.000 = **408.000** | 408.000 ✅ | + +## Mapeo Totales Finales + +| Columna | Ruta XPath / Derivación | Notas | +|---|---|---| +| Importe total | Suma de `lineItem/totalAmount/amount` | O también `order/totalAmount` si existiera | +| Etapa de transporte | `mainCarrierTransport/mode` | | +| Quien transporta | `mainCarrierTransport/carrierName` | | +| Divisa de referencia | `currency` | | +| Condiciones de entrega | — | No existe en el XML | + +## Diferencias entre emisores + +| Elemento | Walmart (925485K200) | Otros emisores | +|---|---|---| +| Namespace | Sin prefijo (`xmlns=`) | Con prefijo `eanucc:` | +| `requestedQuantity/@UnitType` | CSC | CS | +| `itemColor`, `itemSize` | Presente | Ausente | +| `buyerItemNumber`, `vendorItemNumber` | Presente | Ausente | +| `salesDepartamentNumber`, `orderType` | Presente | Ausente | +| `promotionDealNumber` | POS REPLEN | Ausente | +| `internalVendorNumber` | Presente | Ausente | +| `specialHandling` | NO PRETICKET | Ausente | +| `mainCarrierTransport`, `currency` | Presente | Ausente | +| `allowanceCharge` | Ausente | Presente (con descuentos reales o en 0) | + +## Diferencia entre tipos de OC + +- `orderType=20`: Tipo estándar +- `orderType=33`: Tipo distinto (ej. reposición directa) +- Ambos con `promotionDealNumber="POS REPLEN"` + +## Manejo de Namespace + +Los archivos pueden venir con o sin prefijo: +- **Con prefijo**: ``, `` — usar el namespace `eanucc:` para las consultas XPath +- **Sin prefijo**: ``, `` — namespace por default + +Solución recomendada: registrar ambos namespaces y usar `*` o registrar el namespace URI `http://www.uc-council.org/smp/schemas/eanucc` con un alias en el parser. diff --git a/email_reporter.py b/email_reporter.py new file mode 100755 index 0000000..5967951 --- /dev/null +++ b/email_reporter.py @@ -0,0 +1,90 @@ + +# ============================================================================ +def generate_email_html( + data: dict, + cardcode: str, + cardname: str | None, + item_catalog: dict[str, str], + prices: dict[str, float], +) -> str: + cab = data.get("cabecera", {}) + det = data.get("detalle", []) + + num_oc = cab.get("numero_oc", "") or "" + fecha_entrega = (cab.get("fecha_embarque") or cab.get("fecha_generacion") or "")[:10] + cliente = f"{cardcode} — {cardname or ''}" + + rows_html = "" + for d in det: + upc = d.get("cod_upc", "") or "" + desc = d.get("descripcion", "") or "" + empaques = d.get("empaques", 0) or 0 + color_talla = d.get("color_talla_um", "") or "" + unid_emp = d.get("unid_empaque", 0) or 0 + cantidad_unid = d.get("cantidad_unid", 0) or 0 + importe = d.get("importe", 0) or 0 + precio_unidad = round(importe / cantidad_unid) if cantidad_unid > 0 else 0 + + sap_item = item_catalog.get(upc, "") + sap_price = prices.get(sap_item) if sap_item else None + + if not sap_item: + texto = "Sin catálogo" + elif sap_price is None: + texto = "Sin precio SAP" + elif sap_price != precio_unidad: + texto = f"Precio difiere: ${precio_unidad:,} vs ${sap_price:,.0f}" + else: + texto = "OK" + + rows_html += ( + "" + f"{upc}" + f"{empaques:,}" + f"{color_talla}" + f"{unid_emp:,}" + f"{cantidad_unid:,}" + f"{importe:,}" + f"{precio_unidad:,}" + f"{sap_item or '—'}" + f"{f'${sap_price:,.0f}' if sap_price is not None else '—'}" + f"{texto}" + f"{desc}" + "\n" + ) + + html = f""" + + + +

Orden de Compra — {num_oc}

+ + + + +
N° OC:{num_oc}
Fecha Entrega:{fecha_entrega}
Cliente:{cliente}
+ + + + + + + + + + + + + + + + + +{rows_html} + +
cod_upcempaquescolor_talla_umunid_empaquecantidad_unidImporteprecio_unidadItemCodePrecio SAPTextodescripción
+ +""" + + return html +# ============================================================================ \ No newline at end of file diff --git a/logger_manager.py b/logger_manager.py new file mode 100755 index 0000000..f66cea0 --- /dev/null +++ b/logger_manager.py @@ -0,0 +1,116 @@ +import logging +import contextvars +import os +from contextlib import contextmanager +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + +LOG_DIR = Path(__file__).parent / "logs" +LOG_FILE = LOG_DIR / "comercio-net.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): + yield + + @contextmanager + def last_task(self, msg, emoji=""): + with _TaskContext(self, msg, emoji, is_last=True): + yield + + 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) + +# ============================================================================ +def setup_logging(level=None, console=True): + 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 = [file_handler] + if console: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + handlers.append(console_handler) + + logging.basicConfig(level=level, handlers=handlers, force=True) + _LOG_CONFIGURED = True + +# ============================================================================ +def get_logger(name="comercio-net"): + setup_logging() + return TaskLogger(logging.getLogger(name)) \ No newline at end of file diff --git a/mail_sender.py b/mail_sender.py new file mode 100755 index 0000000..ae8fea4 --- /dev/null +++ b/mail_sender.py @@ -0,0 +1,100 @@ +"""Envío de correo SMTP.""" + +import os +import smtplib +from email.message import EmailMessage +from pathlib import Path + +from dotenv import load_dotenv + +# comercio-net/.env o .env en la raíz del repo +# _BASE = Path(__file__).resolve().parent +# load_dotenv(_BASE / ".env") +# load_dotenv(_BASE.parent / ".env") +# ============================================================================ + +load_dotenv(dotenv_path=".env") + +# ============================================================================ +def _mail_config() -> dict: + host = os.getenv("MAIL_HOST", "") + port = int(os.getenv("MAIL_PORT", "587")) + username = os.getenv("MAIL_USERNAME", "") + # App passwords de Gmail suelen pegarse con espacios + password = (os.getenv("MAIL_PASSWORD", "") or "").replace(" ", "") + mail_from = os.getenv("MAIL_FROM", username) + mail_to = os.getenv("MAIL_TO", "") + if not all([host, username, password, mail_from, mail_to]): + raise ValueError( + "Faltan variables MAIL_* en .env " + "(MAIL_HOST, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM, MAIL_TO)" + ) + return { + "host": host, + "port": port, + "username": username, + "password": password, + "mail_from": mail_from, + "mail_to": mail_to, + } + +# ============================================================================ +def _smtp_send(msg: EmailMessage) -> None: + cfg = _mail_config() + with smtplib.SMTP(cfg["host"], cfg["port"], timeout=30) as smtp: + smtp.ehlo() + smtp.starttls() + smtp.ehlo() + smtp.login(cfg["username"], cfg["password"]) + smtp.send_message(msg) + +# ============================================================================ +def send_mail(subject: str | None = None, text: str | None = None) -> None: + """Envía un correo de texto plano usando variables MAIL_* del .env.""" + cfg = _mail_config() + subject = subject if subject is not None else os.getenv("MAIL_SUBJECT", "Test Email") + text = text if text is not None else os.getenv("MAIL_TEXT", "This is a test email") + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = cfg["mail_from"] + msg["To"] = cfg["mail_to"] + msg.set_content(text) + + _smtp_send(msg) + print(f"OK: correo enviado a {cfg['mail_to']} | subject={subject!r}") + +# ============================================================================ +def send_validation_email(html_path: Path | str, subject: str | None = None) -> None: + """Envía el HTML de validación a MAIL_TO (cuerpo HTML + adjunto).""" + cfg = _mail_config() + path = Path(html_path) + if not path.is_file(): + raise FileNotFoundError(f"No existe el HTML de validación: {path}") + + html = path.read_text(encoding="utf-8") + if subject is None: + subject = f"Validación OC — diferencias de precio/catálogo ({path.stem})" + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = cfg["mail_from"] + msg["To"] = cfg["mail_to"] + msg.set_content( + "Se detectaron diferencias de catálogo o precio en la OC. " + "Ver el contenido HTML de este correo. " + ) + msg.add_alternative(html, subtype="html") + #msg.add_attachment( + # html.encode("utf-8"), + # maintype="text", + # subtype="html", + # filename=path.name, + #) + + _smtp_send(msg) + print(f"OK: correo de validación enviado a {cfg['mail_to']} | subject={subject!r}") + +# ============================================================================ +if __name__ == "__main__": + send_mail() diff --git a/main.py b/main.py new file mode 100755 index 0000000..d167c05 --- /dev/null +++ b/main.py @@ -0,0 +1,109 @@ +import sys +import os +import time +import signal +from datetime import datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from logger_manager import get_logger +from service import process_all +# ============================================================================ +HORARIOS = [ + (8, 0, 18, 0, 600, "rapido"), + (18, 0, 23, 0, 3600, "full"), +] + +DIAS_ACTIVOS = [0, 1, 2, 3, 4, 5, 6] + +_running = True + +log = get_logger() + +# ============================================================================ +def obtener_intervalo(dt): + minutos = dt.hour * 60 + dt.minute + for hi, mi, hf, mf, intervalo, modo in HORARIOS: + inicio = hi * 60 + mi + fin = hf * 60 + mf + if inicio <= minutos < fin: + return (intervalo, modo) + return None + +# ============================================================================ +def segundos_hasta_proximo_inicio(dt): + target = dt.replace(hour=HORARIOS[0][0], minute=HORARIOS[0][1], second=0, microsecond=0) + if dt >= target: + target += timedelta(days=1) + return int((target - dt).total_seconds()) + +# ============================================================================ +def segundos_hasta_proximo_dia_activo(dt): + for dias in range(1, 8): + prox = dt + timedelta(days=dias) + if prox.weekday() in DIAS_ACTIVOS: + target = prox.replace(hour=HORARIOS[0][0], minute=HORARIOS[0][1], second=0, microsecond=0) + return int((target - dt).total_seconds()) + return 86400 + +# ============================================================================ +def signal_handler(signum, frame): + global _running + log.info("🛑 Señal de parada recibida. Deteniendo...") + _running = False + +# ============================================================================ +def main(): + global _running + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + log.info("🚀 Servicio ComercioNet - Procesador OC") + log.info("📅 Días activos: {} (0=lun, 6=dom)".format(DIAS_ACTIVOS)) + log.info("🕐 Horarios:") + for hi, mi, hf, mf, intervalo, modo in HORARIOS: + log.info(" {:02d}:{:02d} - {:02d}:{:02d} | cada {}s | {}".format(hi, mi, hf, mf, intervalo, modo)) + + while _running: + ahora = datetime.now() + + if ahora.weekday() not in DIAS_ACTIVOS: + espera = segundos_hasta_proximo_dia_activo(ahora) + prox = ahora + timedelta(seconds=espera) + log.info("📅 Día no activo. Próximo inicio: {}".format(prox.strftime("%a %Y-%m-%d %H:%M:%S"))) + for _ in range(int(espera)): + if not _running: + break + time.sleep(1) + continue + + intervalo_info = obtener_intervalo(ahora) + if not intervalo_info: + espera = segundos_hasta_proximo_inicio(ahora) + prox = ahora + timedelta(seconds=espera) + log.info("⏸️ Fuera de horario. Próximo inicio: {}".format(prox.strftime("%H:%M:%S"))) + for _ in range(int(espera)): + if not _running: + break + time.sleep(1) + continue + + intervalo, modo = intervalo_info + log.info("🔄 Ciclo: {} | modo={} | intervalo={}s".format(ahora.strftime("%Y-%m-%d %H:%M:%S"), modo, intervalo)) + + try: + process_all() + except Exception as e: + log.error("❌ Error en ciclo: {}".format(e)) + + if _running: + log.info("💤 Próximo ciclo [{}] en {}s...".format(modo, intervalo)) + for _ in range(intervalo): + if not _running: + break + time.sleep(1) + + log.info("🛑 Servicio detenido.") + +# ============================================================================ +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..76ed28c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +lxml>=5.0.0 +jinja2>=3.0.0 +requests>=2.28.0 +python-dotenv>=1.0.0 +pymssql>=2.0.0 diff --git a/sapb1_manager.py b/sapb1_manager.py new file mode 100755 index 0000000..6400f4d --- /dev/null +++ b/sapb1_manager.py @@ -0,0 +1,207 @@ +import requests +import urllib3 +import os +import json +from typing import Optional, Dict, Any +from dotenv import load_dotenv +from logger_manager import get_logger +# ============================================================================ +urllib3.disable_warnings() + +load_dotenv(dotenv_path=".env") + +SAPB1_URL = os.getenv("SAP_B1_URL", "") +SAPB1_USER = os.getenv("SAP_B1_USER", "") +SAPB1_PASSWORD = os.getenv("SAP_B1_PASSWORD", "") +SAPB1_COMPANY = os.getenv("SAP_B1_COMPANY", "") +SAPB1_TIMEOUT = float(os.getenv("SAP_B1_TIMEOUT", 15.0)) +SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true" + +_sap_session: dict = {"headers": None, "b1session": None, "routeid": None} +# ============================================================================ +log = get_logger() + +# ============================================================================ +def login() -> dict: + payload = { + "CompanyDB": SAPB1_COMPANY, + "UserName": SAPB1_USER, + "Password": SAPB1_PASSWORD, + } + log.info("🔐 Iniciando sesión en SAP B1 Service Layer...") + resp = requests.post( + f"{SAPB1_URL}/Login", + json=payload, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + if resp.status_code != 200: + raise ConnectionError( + f"Login fallido: HTTP {resp.status_code} - {resp.text}" + ) + + data = resp.json() + b1session = data["SessionId"] + routeid = resp.cookies.get("ROUTEID", "") + + headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"} + _sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid}) + log.info("✅ Sesión SAP B1 establecida (B1SESSION: {}...)".format(b1session[:10])) + return headers + +# ============================================================================ +def logout() -> None: + if not _sap_session["b1session"]: + log.warning("⚠️ No hay sesión activa para cerrar.") + return + log.info("🔓 Cerrando sesión SAP B1...") + try: + resp = requests.post( + f"{SAPB1_URL}/Logout", + headers=_sap_session["headers"], + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + if resp.status_code == 200: + log.info("✅ Sesión SAP B1 cerrada correctamente.") + else: + log.warning("⚠️ Logout respondió HTTP {}".format(resp.status_code)) + except Exception as exc: + log.error("❌ Error al cerrar sesión: {}".format(exc)) + finally: + _sap_session.update({"headers": None, "b1session": None, "routeid": None}) + +# ============================================================================ +def _ensure_session() -> dict: + if _sap_session["headers"]: + return _sap_session["headers"] + return login() + +# ============================================================================ +def _log_response(method: str, endpoint: str, response: requests.Response, payload: Any = None) -> None: + log_line = "[{}] {} → {} ({} bytes)".format( + method, endpoint, response.status_code, len(response.content) + ) + if response.status_code >= 400: + log.warning("⚠️ {} - {}".format(log_line, response.text[:300])) + else: + log.info("🔄 " + log_line) + +# ============================================================================ +def forward_to_sapb1( + method: str, + endpoint_path: str, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Dict[str, str]] = None, + raise_on_error: bool = True, +) -> requests.Response: + if not method or not endpoint_path: + raise ValueError("method y endpoint_path son requeridos") + + method = method.upper() + headers = _ensure_session() + + url = f"{SAPB1_URL}/{endpoint_path}" + req_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + **_sap_session["headers"], + } + + if extra_headers: + req_headers.update(extra_headers) + + try: + resp = requests.request( + method=method, + url=url, + json=json_data, + params=params, + headers=req_headers, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + except requests.exceptions.Timeout: + raise ConnectionError(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}") + except requests.RequestException as exc: + raise ConnectionError(f"Error en solicitud HTTP: {exc}") + + if resp.status_code == 401: + log.warning("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...") + req_headers["Cookie"] = login()["Cookie"] + resp = requests.request( + method=method, + url=url, + json=json_data, + params=params, + headers=req_headers, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + + _log_response(method, endpoint_path, resp, json_data) + + if raise_on_error and resp.status_code >= 400: + try: + error_body = resp.json() + sap_message = ( + error_body.get("error", {}) + .get("message", {}) + .get("value", resp.text) + ) + except (json.JSONDecodeError, AttributeError): + sap_message = resp.text + raise ConnectionError( + f"Error en SAP {method} {endpoint_path}: " + f"HTTP {resp.status_code} - {sap_message}" + ) + + return resp + +# ============================================================================ +def create_drafts(payload: dict) -> dict: + resp = forward_to_sapb1("POST", "Drafts", json_data=payload, raise_on_error=False) + + if resp.status_code == 201: + result = resp.json() + log.info("✅ Draft creado DocEntry={}".format(result.get("DocEntry"))) + return result + + try: + error_body = resp.json() + sap_message = ( + error_body.get("error", {}) + .get("message", {}) + .get("value", resp.text) + ) + except (json.JSONDecodeError, AttributeError): + sap_message = resp.text + raise ConnectionError( + f"Error creando Draft: HTTP {resp.status_code} - {sap_message}" + ) + +# ============================================================================ +def create_orders(payload: dict) -> dict: + resp = forward_to_sapb1("POST", "Orders", json_data=payload, raise_on_error=False) + + if resp.status_code == 201: + result = resp.json() + log.info("✅ Orden creada DocEntry={}".format(result.get("DocEntry"))) + return result + + try: + error_body = resp.json() + sap_message = ( + error_body.get("error", {}) + .get("message", {}) + .get("value", resp.text) + ) + except (json.JSONDecodeError, AttributeError): + sap_message = resp.text + raise ConnectionError( + f"Error creando Orden venta: HTTP {resp.status_code} - {sap_message}" + ) +# ============================================================================ \ No newline at end of file diff --git a/sapb1_mapper.py b/sapb1_mapper.py new file mode 100755 index 0000000..397aa0b --- /dev/null +++ b/sapb1_mapper.py @@ -0,0 +1,77 @@ + +# ============================================================================ +def to_draft_payload( + data: dict, + cardcode: str | None = None, + city: str | None = None, + county: str | None = None, + item_catalog: dict[str, str] | None = None, + prices: dict[str, float] | None = None, +) -> dict: + cab = data.get("cabecera", {}) + det = data.get("detalle", []) + + doc_date = (cab.get("fecha_generacion") or "")[:10] + due_date = (cab.get("fecha_embarque") or doc_date or "")[:10] + + resolved_cardcode = cardcode or cab.get("emisor", "") + catalog = item_catalog or {} + price_map = prices or {} + + comments = cab.get("condiciones_pago_texto") or cab.get("condiciones_pago") or "" + obs = cab.get("observaciones", "") + if obs: + comments = (comments + " | " + obs) if comments else obs + + lines = [] + for d in det: + codigo_upc = d.get("cod_upc", "") + sap_item = catalog.get(codigo_upc, "") + precio_empaque = d.get("precio_efectivo",0.00) + sap_price = price_map.get(sap_item,0.00) + + #print(f" SAP Item={sap_item}") + #print(f" Tamaño {len(sap_item)}") + + if sap_item is not None and len(sap_item)>0: + + lines.append({ + "ItemCode": sap_item, + # "ItemDescription": d.get("descripcion", "") or "", + "Quantity": d.get("cantidad_unid",0.00), + ##"Quantity": d.get("empaques", 1), + "MeasureUnit": "UND", #d.get("color_talla_um",""), + ##"UnitPrice": precio_empaque, + ##"UnitsOfMeasurment":d.get("unid_empaque", 0.00), + "PackQuantity":d.get("empaques", 1), + "TaxCode": "IVA", + "LineTotal": d.get("importe",0.00), + "FreeText": codigo_upc, + "U_B2B_CANAL": "B2B", + }) + + shipto_payto = cab.get("emisor","") + "." + cab.get("lugar_entrega","") + + address_extension = { + "BillToCity":city, + "BillToCounty":county + } + + payload = { + "CardCode": resolved_cardcode, + "DocObjectCode":"oOrders", + "DocDate": doc_date, + "DocDueDate": due_date, + "TaxDate": doc_date, + "NumAtCard": cab.get("numero_oc", "") or "", + "ShipToCode": shipto_payto, + "PayToCode": shipto_payto, + "Comments": comments or "", + "U_VENT_B2B": cab.get("emisor", "") or "", + "DocType": "I", + "DocumentLines": lines, + "AddressExtension":address_extension + } + + return payload +# ============================================================================ \ No newline at end of file diff --git a/service.py b/service.py new file mode 100755 index 0000000..82bbe0f --- /dev/null +++ b/service.py @@ -0,0 +1,313 @@ +import json +import os +import shutil +from pathlib import Path +from datetime import datetime +from dotenv import load_dotenv +from lxml import etree +from jinja2 import Environment, FileSystemLoader +from logger_manager import get_logger + +# ============================================================================ +_BASE = Path(__file__).resolve().parent +# load_dotenv(_BASE / ".env") +# load_dotenv(_BASE.parent / ".env") + +load_dotenv(dotenv_path=".env") + +INPUT_DIR = Path(os.getenv("INPUT_DIR", "data/input")) +PROCESSED_DIR = Path("data/processed") +OUTPUT_DIR = Path("data/output") +HTML_DIR = Path("data/html") +SAP_B1_DIR = Path("data/sap_b1") +TEMPLATE_FILE = _BASE / "template.html" + +POST_TO_SAP_B1 = True + +TEXT_MONTHS = { + 1: "enero", 2: "febrero", 3: "marzo", 4: "abril", + 5: "mayo", 6: "junio", 7: "julio", 8: "agosto", + 9: "septiembre", 10: "octubre", 11: "noviembre", 12: "diciembre", +} +# ============================================================================ +log = get_logger() + +# ============================================================================ +def _format_date(d: str) -> str: + if not d: + return "" + p = d.split("-") + if len(p) == 3: + return f"{p[2][:2]} de {TEXT_MONTHS.get(int(p[1]), p[1])} de {p[0]}" + return d + +# ============================================================================ +def _format_datetime(dt: str) -> str: + if not dt: + return "" + dp = dt.split("T")[0] + tp = dt.split("T")[1][:5] if "T" in dt else "" + fd = _format_date(dp) + return f"{fd} {tp} hrs." if tp else fd + +# ============================================================================ +def _event_text(ev: str) -> str: + return {"DATE_OF_INVOICE": "fecha de factura", "RECEIPT_OF_GOODS": "recepción de mercadería"}.get(ev, ev or "") + +# ============================================================================ +def apply_allowances(net_price: int, qty: int, allowances: list) -> tuple: + total = net_price * qty + applied = [] + for alw in allowances: + pct = alw.get("percentage", 0) + if pct > 0: + dsc = round(total * pct / 100) + else: + dsc = round(alw.get("amount", 0)) + applied.append({"type": alw.get("type"), "percentage": pct, "amount": dsc}) + total -= dsc + return total, applied + +# ============================================================================ +def _strip_ns(root): + for el in root.iter(): + if "}" in el.tag: + el.tag = el.tag.split("}", 1)[1] + etree.cleanup_namespaces(root) + return root + +# ============================================================================ +def _x(node, path, default=None): + v = node.xpath(path) + return v[0] if v else default + +# ============================================================================ +def _t(node, path, default=""): + v = _x(node, path) + return v.text.strip() if v is not None and v.text else default + +# ============================================================================ +def _a(node, path, attr, default=""): + v = _x(node, path) + return v.get(attr, default) if v is not None else default + +# ============================================================================ +def _l(node, path): + return node.xpath(path) + +# ============================================================================ +def substring_desde_fin(texto: str, cantidad: int = 10, excluir_ultimos: int = 1) -> str: + inicio = -(cantidad + excluir_ultimos) + fin = -excluir_ultimos if excluir_ultimos > 0 else None + return texto[inicio:fin] + +# ============================================================================ +def parse_order(xml_bytes: bytes, filename: str = "") -> dict: + root = _strip_ns(etree.fromstring(xml_bytes)) + order = _x(root, "//order") + if order is None: + raise ValueError("Elemento no encontrado") + + msg = _x(root, "//messageHeader") + oc_id = _x( + order, + 'typedEntityIdentification[@entityType="ORDER"]/entityIdentification/uniqueCreatorIdentification', + ) + oc_num = oc_id.text.strip() if oc_id is not None and oc_id.text else "" + + cd = order.get("creationDate", "") + pt = _x(order, "paymentTerms") + event = pt.get("eventType", "") if pt is not None else "" + net_days = _t(order, "paymentTerms/netPayment/timePeriodDue") + + cab = { + "emisor": _t(msg, "from/gln") if msg is not None else "", + "receptor": _t(msg, "to/gln") if msg is not None else "", + "numero_oc": substring_desde_fin(oc_num) if (oc_num is not None and len(oc_num) > 10) else "", + "fecha_generacion": cd, + "fecha_generacion_format": _format_datetime(cd), + "fecha_embarque": _t(order, "movementDate"), + "fecha_embarque_format": _format_date(_t(order, "movementDate")), + "fecha_cancelacion": "", + "fecha_cancelacion_format": "", + "condiciones_pago": f"NET {net_days} días desde {_event_text(event)}" if net_days else "", + "condiciones_pago_texto": f"NET {net_days} días desde {_event_text(event)}" if net_days else "", + "lugar_entrega": _t(order, 'shipParty[@identificationType="SHIP_TO"]/gln'), + "dpto_ventas": _t(order, "salesDepartamentNumber"), + "tipo_oc": _t(order, "orderType"), + "promocion": _t(order, "promotionDealNumber"), + "numero_proveedor": _t(order, "internalVendorNumber"), + "observaciones": _t(order, "specialHandling"), + } + + carrier = _x(root, "//mainCarrierTransport") + if carrier is not None: + cab["etapa_transporte"] = _t(carrier, "mode") + cab["transportista"] = _t(carrier, "carrierName") + cab["divisa"] = _t(carrier, "../../currency") or _t(carrier, "../../../currency") or _t(carrier, "../currency") + + detalle = [] + for item in _l(order, "lineItem"): + np = int(_t(item, "netPrice/amount", "0")) + rq = int(_t(item, "requestedQuantity", "0")) + cu = int(_t(item, "containedUnits", "0")) + ta = int(_t(item, "totalAmount/amount", "0")) + currency = _a(item, "netPrice/amount", "currencyISOcode", "CLP") + + alw_list = [] + for alw in _l(item, "allowanceCharge"): + alw_list.append({ + "type": alw.get("allowanceOrChargeType", ""), + "percentage": int(_t(alw, "monetaryAmountOrPercentage/percentage", "0")), + "amount": int(_t(alw, "monetaryAmountOrPercentage/amount", "0")), + }) + + calc_total, applied_alw = apply_allowances(np, rq, alw_list) + color = _t(item, "itemColor") + size = _t(item, "itemSize") + + detalle.append({ + "linea": item.get("number", ""), + "cod_upc": _t(item, "itemIdentification/gtin"), + "item": _t(item, "itemIdentification/buyerItemNumber"), + "cod_prov": _t(item, "itemIdentification/vendorItemNumber"), + "color_talla_um": "/ ".join(filter(None, [color, size])), + "descripcion": _t(item, 'itemDescription[@language="SP"]/text'), + "precio_original": np, + "moneda": currency, + "allowances": applied_alw, + "precio_efectivo": round(calc_total / rq) if rq > 0 else 0, + "empaques": rq, + "unidad_empaque": _a(item, "requestedQuantity", "UnitType", ""), + "unid_empaque": cu, + "cantidad_unid": rq * cu, + "importe": ta, + }) + + return { + "metadata": {"archivo": filename, "procesado": datetime.now().isoformat()}, + "cabecera": cab, + "detalle": detalle, + "totales": { + "importe_total": sum(d["importe"] for d in detalle), + "moneda": detalle[0]["moneda"] if detalle else "CLP", + }, + } + +# ============================================================================ +def write_json(data: dict, path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +# ============================================================================ +def write_html(data: dict, template_path: Path, output_path: Path): + env = Environment(loader=FileSystemLoader(str(template_path.parent))) + template = env.get_template(template_path.name) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(template.render(data=data)) + +# ============================================================================ +def process_file(xml_path: Path): + with log.task(f"📦 {xml_path.name}"): + log.info("📄 XML parseado") + with open(xml_path, "rb") as f: + data = parse_order(f.read(), filename=xml_path.name) + + base = xml_path.stem + write_json(data, OUTPUT_DIR / f"{base}.json") + log.info("💾 JSON guardado") + + write_html(data, TEMPLATE_FILE, HTML_DIR / f"{base}.html") + log.info("💾 HTML generado") + + if POST_TO_SAP_B1: + with log.task("🔐 SAP B1"): + try: + from sapb1_mapper import to_draft_payload + from sapb1_manager import create_orders + from sql_manager import get_client_by_gln, get_item_catalog, get_price_list, get_prices, get_card_name + from email_reporter import generate_email_html + + cab = data.get("cabecera", {}) + emisor = cab.get("emisor", "") + lugar_entrega = cab.get("lugar_entrega", "") + + result = get_client_by_gln(emisor, lugar_entrega) + + if result: + cardcode = result["CardCode"] + city = result["City"] + county = result["County"] + log.info("CardCode={} (desde CRD1)".format(cardcode)) + else: + cardcode = emisor + city = "" + county = "" + log.info("CardCode={} (fallback emisor)".format(cardcode)) + + item_catalog = get_item_catalog(cardcode) + pricelist = get_price_list(cardcode) + prices = get_prices(pricelist) if pricelist else {} + log.info("Catálogo: {} items, precios: {} registros".format(len(item_catalog), len(prices))) + + cardname = get_card_name(cardcode) + email_html = generate_email_html(data, cardcode, cardname, item_catalog, prices) + (HTML_DIR / f"{base}_email.html").write_text(email_html, encoding="utf-8") + log.info("Email HTML generado") + + payload = to_draft_payload(data, cardcode=cardcode, city=city, county=county, item_catalog=item_catalog, prices=prices) + write_json(payload, SAP_B1_DIR / f"{base}_sap.json") + log.info("Payload guardado en data/sap_b1/") + + det = data.get("detalle", []) + price_ok = True + for d in det: + upc = d.get("cod_upc", "") or "" + sap_item = item_catalog.get(upc, "") + sap_price = prices.get(sap_item) if sap_item else None + importe = d.get("importe", 0) or 0 + qty = d.get("cantidad_unid", 1) or 1 + precio_xml = round(importe / qty) if qty > 0 else 0 + if not sap_item or sap_price is None or sap_price != precio_xml: + price_ok = False + log.warning("⚠️ {}: difiere (XML={} vs SAP={})".format(upc, precio_xml, sap_price)) + + if not price_ok: + email_path = HTML_DIR / f"{base}_email.html" + num_oc = cab.get("numero_oc", "") or base + try: + from mail_sender import send_validation_email + send_validation_email( + email_path, + subject=f"Validación OC {num_oc} — diferencias precio/catálogo", + ) + log.info("Notificación enviada a MAIL_TO") + except Exception as e: + log.warning("Error enviando correo: {}".format(e)) + + create_orders(payload) + except ImportError as e: + log.warning("⚠️ SAP saltado (dependencia faltante: {})".format(e)) + except Exception as e: + log.warning("❌ SAP error: {}".format(e)) + + PROCESSED_DIR.mkdir(parents=True, exist_ok=True) + shutil.move(str(xml_path), str(PROCESSED_DIR / xml_path.name)) + log.info("📁 XML movido a processed/") + +# ============================================================================ +def process_all(): + INPUT_DIR.mkdir(parents=True, exist_ok=True) + files = sorted(f for f in INPUT_DIR.iterdir() if f.is_file()) + if not files: + return + log.info("📦 Archivos pendientes: {}".format(len(files))) + for f in files: + try: + process_file(f) + except Exception as e: + log.error("❌ Error con {}: {}".format(f.name, e)) + +# ============================================================================ \ No newline at end of file diff --git a/sql_manager.py b/sql_manager.py new file mode 100755 index 0000000..40ad580 --- /dev/null +++ b/sql_manager.py @@ -0,0 +1,110 @@ +import os +import pymssql +from dotenv import load_dotenv +from logger_manager import get_logger +# ============================================================================ +load_dotenv(dotenv_path=".env") + +SQL_HOST = os.getenv("SQL_HOST", "") +SQL_USER = os.getenv("SQL_USER", "") +SQL_PASSWORD = os.getenv("SQL_PASSWORD", "") +SQL_DATABASE = os.getenv("SQL_DATABASE", "") +# ============================================================================ +_conn = None + +log = get_logger() + +# ============================================================================ +def get_connection(): + global _conn + if _conn is None: + try: + _conn = pymssql.connect( + server=SQL_HOST, + user=SQL_USER, + password=SQL_PASSWORD, + database=SQL_DATABASE, + ) + except Exception as e: + log.error("❌ Error conectando a SQL Server: {}".format(e)) + return None + return _conn + +# ============================================================================ +def execute_query(query: str, params: tuple = ()) -> list[dict]: + conn = get_connection() + if not conn: + return [] + try: + cursor = conn.cursor() + if params: + cursor.execute(query, params) + else: + cursor.execute(query) + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + cursor.close() + return [dict(zip(columns, row)) for row in rows] + except Exception as e: + log.error("❌ Error en query: {}".format(e)) + return [] + +# ============================================================================ +def get_client_by_gln(emisor: str, lugar_entrega: str) -> str | None: + query = ( + "SELECT [Address],[CardCode],[Street],[City],[Country],[County] " + "FROM CRD1 T1 " + f"WHERE [AdresType] = 'S' " + f"AND T1.[Address] LIKE '%{emisor}.{lugar_entrega}%'" + ) + rows = execute_query(query) + + if not rows: + return None + row = rows[0] + return { + "CardCode": row.get("CardCode"), + "City": row.get("City"), + "County": row.get("County") + } + +# ============================================================================ +def get_item_catalog(cardcode: str) -> dict[str, str]: + rows = execute_query( + "SELECT [Substitute], [ItemCode] FROM OSCN " + f"WHERE [CardCode] = '{cardcode}'" + ) + return {r["Substitute"]: r["ItemCode"] for r in rows if r.get("Substitute")} + +# ============================================================================ +def get_price_list(cardcode: str) -> int | None: + rows = execute_query( + f"SELECT T0.[ListNum] FROM OCRD T0 WHERE T0.[CardCode] = '{cardcode}'" + ) + return rows[0].get("ListNum") if rows else None + +# ============================================================================ +def get_prices(pricelist: int) -> dict[str, float]: + rows = execute_query( + "SELECT [ItemCode],[Price] FROM ITM1 " + f"WHERE [PriceList] = {pricelist} AND [Price] > 0.000000" + ) + return {r["ItemCode"]: float(r["Price"]) for r in rows if r.get("Price")} + +# ============================================================================ +def get_card_name(cardcode: str) -> str | None: + rows = execute_query( + f"SELECT [CardName] FROM OCRD WHERE [CardCode] = '{cardcode}'" + ) + return rows[0].get("CardName") if rows else None + +# ============================================================================ +def cerrar_conexion(): + global _conn + if _conn: + try: + _conn.close() + except: + pass + _conn = None +# ============================================================================ \ No newline at end of file diff --git a/template.html b/template.html new file mode 100755 index 0000000..5f5102e --- /dev/null +++ b/template.html @@ -0,0 +1,143 @@ + + + + + +OC {{ data.cabecera.numero_oc }} + + + +
+

Orden de Compra — {{ data.cabecera.numero_oc }}

+ +

Cabecera

+
+
Emisor{{ data.cabecera.emisor or "—" }}
+
Receptor{{ data.cabecera.receptor or "—" }}
+
N° OC{{ data.cabecera.numero_oc or "—" }}
+
Fecha generación{{ data.cabecera.fecha_generacion_format or data.cabecera.fecha_generacion or "—" }}
+
Fecha embarque{{ data.cabecera.fecha_embarque_format or data.cabecera.fecha_embarque or "—" }}
+
Fecha cancelación{{ data.cabecera.fecha_cancelacion_format or data.cabecera.fecha_cancelacion or "—" }}
+
Condiciones de pago{{ data.cabecera.condiciones_pago_texto or data.cabecera.condiciones_pago or "—" }}
+
Lugar de entrega{{ data.cabecera.lugar_entrega or "—" }}
+
Dpto. ventas{{ data.cabecera.dpto_ventas or "—" }}
+
Tipo OC{{ data.cabecera.tipo_oc or "—" }}
+
Promoción{{ data.cabecera.promocion or "—" }}
+
N° Proveedor{{ data.cabecera.numero_proveedor or "—" }}
+
Observaciones{{ data.cabecera.observaciones or "—" }}
+
Transporte{{ data.cabecera.etapa_transporte or "—" }}
+
Transportista{{ data.cabecera.transportista or "—" }}
+
Divisa{{ data.cabecera.divisa or "—" }}
+
+ +

Detalle

+ + + + + + + + + + + + + + + + + + + + {% for d in data.detalle %} + + + + + + + + + + + + + + + + {% endfor %} + +
#Cód. UPCItemCód. Prov.Color/Talla/UMDescripciónPrecio Orig.Dto.Precio Efec.Emp.U/ECant. (unid)Importe
{{ d.linea }}{{ d.cod_upc or "—" }}{{ d.item or "—" }}{{ d.cod_prov or "—" }}{{ d.color_talla_um or "—" }}{{ d.descripcion or "—" }}{{ "{:,.0f}".format(d.precio_original) }} + {% if d.allowances %} + {% for alw in d.allowances %} + {% if alw.percentage > 0 %} + {{ alw.percentage }}% + {% endif %} + {% endfor %} + {% else %} + — + {% endif %} + {{ "{:,.0f}".format(d.precio_efectivo) }}{{ d.empaques }}{{ d.unid_empaque }}{{ "{:,.0f}".format(d.cantidad_unid) }}{{ "{:,.0f}".format(d.importe) }}
+ +

Totales Finales

+
+
+
Importe total
+
{{ "{:,.0f}".format(data.totales.importe_total) }} {{ data.totales.moneda }}
+
+ {% if data.cabecera.etapa_transporte %} +
+
Etapa de transporte
+
{{ data.cabecera.etapa_transporte }}
+
+ {% endif %} + {% if data.cabecera.transportista %} +
+
Quien transporta
+
{{ data.cabecera.transportista }}
+
+ {% endif %} + {% if data.cabecera.divisa %} +
+
Divisa de referencia
+
{{ data.cabecera.divisa }}
+
+ {% endif %} +
+
Condiciones de entrega
+
—
+
+
+ + +
+ + diff --git a/test/b1.json b/test/b1.json new file mode 100644 index 0000000..31b6521 --- /dev/null +++ b/test/b1.json @@ -0,0 +1,32 @@ +{ + "CarCode": "", + "Series": 1, + "DocDate": "2026-07-21", + "DocDueDate": "2026-07-21", + "TaxDate": "2026-07-21", + "NumAtCard": "", + "ShipToCode": "", + "PayToCode": "", + "Comments": "", + "U_VENT_B2B": "", + "DocType": "dDocument_Items", + "DocObjectCode": "oOrders", + "BPL_IDAssignedToInvoice": null, + "DocumentLines": [ + { + "ItemCode": "", + "ItemDescription": "", + "Quantity": 1, + "PriceAfterVAT": 1, + "WarehouseCode": "", + "Price": 0.00, + "UnitPrice": 0, + "TaxCode": "IVA", + "FreeText": "", + "U_B2B_CANAL": "B2B", + "LineTotal": 0.00, + "MeasureUnit": "", + "UnitsOfMeasurment": 1.0 + } + ] +} \ No newline at end of file diff --git a/test/sl.http b/test/sl.http new file mode 100644 index 0000000..7e1971a --- /dev/null +++ b/test/sl.http @@ -0,0 +1,17 @@ +@url=https://b1sap:30030/b1s/v1 +@user=manager +@password=3210 +@company= + +### +POST {{url}}/Login +Content-Type: application/json + +{ + "CompanyDB":"ZZ_TST_GLOBEITALIA", + "UserName": "manager", + "Password": "3210" +} + +### +GET {{url}}/Orders(328001) \ No newline at end of file