desarrollo instalado en el cliente globe italia
This commit is contained in:
commit
d7c4d532b5
17 files changed
+1946
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
logs/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:sans-serif;font-size:13px">
|
||||||
|
<h2>Orden de Compra — 780790022025012792230</h2>
|
||||||
|
<table style="border-collapse:collapse;margin-bottom:16px">
|
||||||
|
<tr><td><strong>N° OC:</strong></td><td>780790022025012792230</td></tr>
|
||||||
|
<tr><td><strong>Fecha Entrega:</strong></td><td>2026-07-17</td></tr>
|
||||||
|
<tr><td><strong>Cliente:</strong></td><td>C000003-327 — CLIENTE PRUEBA SA</td></tr>
|
||||||
|
</table>
|
||||||
|
<table style="border-collapse:collapse;width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#eee">
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">cod_upc</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">empaques</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">color_talla_um</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">unid_empaque</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">cantidad_unid</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">Importe</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">precio_unidad</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">ItemCode</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">Precio SAP</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">Texto</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">descripción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>17730976822923</td><td style='text-align:right'>510</td><td>CAJ/ 520GR</td><td style='text-align:right'>1</td><td style='text-align:right'>510</td><td style='text-align:right'>4,155,480</td><td style='text-align:right'>8,148</td><td>SUB-001</td><td style='text-align:right'>$8,148</td><td>OK</td><td>PURE DE TOMATE CJ</td></tr>
|
||||||
|
<tr><td>17730976822381</td><td style='text-align:right'>476</td><td>CAJ/ 1030GR</td><td style='text-align:right'>1</td><td style='text-align:right'>476</td><td style='text-align:right'>7,202,832</td><td style='text-align:right'>15,132</td><td>SUB-002</td><td style='text-align:right'>$16,000</td><td>Precio difiere: $15,132 vs $16,000</td><td>PURE DE TOMATE CJ</td></tr>
|
||||||
|
<tr><td>99999999999999</td><td style='text-align:right'>10</td><td></td><td style='text-align:right'>10</td><td style='text-align:right'>100</td><td style='text-align:right'>500,000</td><td style='text-align:right'>5,000</td><td>—</td><td style='text-align:right'>—</td><td>Sin catálogo</td><td>ITEM SIN CATALOGO</td></tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+265
@@ -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 `<order>`, 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.
|
||||||
@@ -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/
|
||||||
|
```
|
||||||
@@ -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**: `<eanucc:envelope>`, `<eanucc:order>` — usar el namespace `eanucc:` para las consultas XPath
|
||||||
|
- **Sin prefijo**: `<envelope>`, `<order>` — 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.
|
||||||
Executable
+90
@@ -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 += (
|
||||||
|
"<tr>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px'>{upc}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{empaques:,}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px'>{color_talla}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{unid_emp:,}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{cantidad_unid:,}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{importe:,}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{precio_unidad:,}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px'>{sap_item or '—'}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px;text-align:right'>{f'${sap_price:,.0f}' if sap_price is not None else '—'}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px'>{texto}</td>"
|
||||||
|
f"<td style='border:1px solid #ccc;padding:6px'>{desc}</td>"
|
||||||
|
"</tr>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:sans-serif;font-size:13px">
|
||||||
|
<h2>Orden de Compra — {num_oc}</h2>
|
||||||
|
<table style="border-collapse:collapse;margin-bottom:16px">
|
||||||
|
<tr><td style='border:1px solid #ccc;padding:6px'><strong>N° OC:</strong></td><td style='border:1px solid #ccc;padding:6px'>{num_oc}</td></tr>
|
||||||
|
<tr><td style='border:1px solid #ccc;padding:6px'><strong>Fecha Entrega:</strong></td><td style='border:1px solid #ccc;padding:6px'>{fecha_entrega}</td></tr>
|
||||||
|
<tr><td style='border:1px solid #ccc;padding:6px'><strong>Cliente:</strong></td><td style='border:1px solid #ccc;padding:6px'>{cliente}</td></tr>
|
||||||
|
</table>
|
||||||
|
<table style="border-collapse:collapse;width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#eee">
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">cod_upc</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">empaques</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">color_talla_um</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">unid_empaque</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">cantidad_unid</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">Importe</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">precio_unidad</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">ItemCode</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:right">Precio SAP</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">Texto</th>
|
||||||
|
<th style="border:1px solid #ccc;padding:6px;text-align:left">descripción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows_html}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
# ============================================================================
|
||||||
Executable
+116
@@ -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))
|
||||||
Executable
+100
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+5
@@ -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
|
||||||
Executable
+207
@@ -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}"
|
||||||
|
)
|
||||||
|
# ============================================================================
|
||||||
Executable
+77
@@ -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
|
||||||
|
# ============================================================================
|
||||||
Executable
+313
@@ -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 <order> 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))
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
Executable
+110
@@ -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
|
||||||
|
# ============================================================================
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OC {{ data.cabecera.numero_oc }}</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-size: 13px; color: #222; background: #f5f5f5; padding: 20px; }
|
||||||
|
.container { max-width: 100%; margin: 0 auto; background: #fff; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 24px; }
|
||||||
|
h1 { font-size: 18px; color: #1a1a2e; margin-bottom: 20px; border-bottom: 2px solid #e0e0e0; padding-bottom: 10px; }
|
||||||
|
h2 { font-size: 14px; color: #1a1a2e; margin: 20px 0 10px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; font-size: 12px; }
|
||||||
|
th, td { padding: 6px 8px; text-align: left; border: 1px solid #ddd; }
|
||||||
|
th { background: #f0f0f0; font-weight: 600; white-space: nowrap; }
|
||||||
|
tr:nth-child(even) td { background: #fafafa; }
|
||||||
|
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
|
||||||
|
.badge-green { background: #e6f7e6; color: #1a7a1a; }
|
||||||
|
.badge-red { background: #fde8e8; color: #b91c1c; }
|
||||||
|
.badge-gray { background: #f0f0f0; color: #555; }
|
||||||
|
.totals-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||||
|
.total-card { background: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; }
|
||||||
|
.total-card .label { font-size: 11px; color: #666; }
|
||||||
|
.total-card .value { font-size: 16px; font-weight: 700; color: #1a1a2e; margin-top: 2px; }
|
||||||
|
.allowance-row { font-size: 11px; color: #555; }
|
||||||
|
.cabecera-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 6px 20px; margin: 12px 0; }
|
||||||
|
.cabecera-item { display: flex; border-bottom: 1px solid #eee; padding: 4px 0; }
|
||||||
|
.cabecera-item .label { font-weight: 600; min-width: 130px; color: #555; }
|
||||||
|
.cabecera-item .value { color: #222; }
|
||||||
|
.footer { margin-top: 20px; padding-top: 12px; border-top: 1px solid #e0e0e0; font-size: 11px; color: #888; text-align: center; }
|
||||||
|
@media print { body { background: #fff; padding: 0; } .container { box-shadow: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Orden de Compra — {{ data.cabecera.numero_oc }}</h1>
|
||||||
|
|
||||||
|
<h2>Cabecera</h2>
|
||||||
|
<div class="cabecera-grid">
|
||||||
|
<div class="cabecera-item"><span class="label">Emisor</span><span class="value">{{ data.cabecera.emisor or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Receptor</span><span class="value">{{ data.cabecera.receptor or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">N° OC</span><span class="value">{{ data.cabecera.numero_oc or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Fecha generación</span><span class="value">{{ data.cabecera.fecha_generacion_format or data.cabecera.fecha_generacion or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Fecha embarque</span><span class="value">{{ data.cabecera.fecha_embarque_format or data.cabecera.fecha_embarque or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Fecha cancelación</span><span class="value">{{ data.cabecera.fecha_cancelacion_format or data.cabecera.fecha_cancelacion or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Condiciones de pago</span><span class="value">{{ data.cabecera.condiciones_pago_texto or data.cabecera.condiciones_pago or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Lugar de entrega</span><span class="value">{{ data.cabecera.lugar_entrega or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Dpto. ventas</span><span class="value">{{ data.cabecera.dpto_ventas or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Tipo OC</span><span class="value">{{ data.cabecera.tipo_oc or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Promoción</span><span class="value">{{ data.cabecera.promocion or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">N° Proveedor</span><span class="value">{{ data.cabecera.numero_proveedor or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Observaciones</span><span class="value">{{ data.cabecera.observaciones or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Transporte</span><span class="value">{{ data.cabecera.etapa_transporte or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Transportista</span><span class="value">{{ data.cabecera.transportista or "—" }}</span></div>
|
||||||
|
<div class="cabecera-item"><span class="label">Divisa</span><span class="value">{{ data.cabecera.divisa or "—" }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Detalle</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Cód. UPC</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Cód. Prov.</th>
|
||||||
|
<th>Color/Talla/UM</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Precio Orig.</th>
|
||||||
|
<th>Dto.</th>
|
||||||
|
<th>Precio Efec.</th>
|
||||||
|
<th>Emp.</th>
|
||||||
|
<th>U/E</th>
|
||||||
|
<th>Cant. (unid)</th>
|
||||||
|
<th>Importe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in data.detalle %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.linea }}</td>
|
||||||
|
<td>{{ d.cod_upc or "—" }}</td>
|
||||||
|
<td>{{ d.item or "—" }}</td>
|
||||||
|
<td>{{ d.cod_prov or "—" }}</td>
|
||||||
|
<td>{{ d.color_talla_um or "—" }}</td>
|
||||||
|
<td>{{ d.descripcion or "—" }}</td>
|
||||||
|
<td style="text-align:right">{{ "{:,.0f}".format(d.precio_original) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if d.allowances %}
|
||||||
|
{% for alw in d.allowances %}
|
||||||
|
{% if alw.percentage > 0 %}
|
||||||
|
<span class="badge badge-red">{{ alw.percentage }}%</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-gray">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right">{{ "{:,.0f}".format(d.precio_efectivo) }}</td>
|
||||||
|
<td style="text-align:right">{{ d.empaques }}</td>
|
||||||
|
<td style="text-align:right">{{ d.unid_empaque }}</td>
|
||||||
|
<td style="text-align:right">{{ "{:,.0f}".format(d.cantidad_unid) }}</td>
|
||||||
|
<td style="text-align:right">{{ "{:,.0f}".format(d.importe) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Totales Finales</h2>
|
||||||
|
<div class="totals-grid">
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="label">Importe total</div>
|
||||||
|
<div class="value">{{ "{:,.0f}".format(data.totales.importe_total) }} {{ data.totales.moneda }}</div>
|
||||||
|
</div>
|
||||||
|
{% if data.cabecera.etapa_transporte %}
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="label">Etapa de transporte</div>
|
||||||
|
<div class="value" style="font-size:13px">{{ data.cabecera.etapa_transporte }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.cabecera.transportista %}
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="label">Quien transporta</div>
|
||||||
|
<div class="value" style="font-size:13px">{{ data.cabecera.transportista }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.cabecera.divisa %}
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="label">Divisa de referencia</div>
|
||||||
|
<div class="value" style="font-size:13px">{{ data.cabecera.divisa }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="total-card">
|
||||||
|
<div class="label">Condiciones de entrega</div>
|
||||||
|
<div class="value" style="font-size:13px">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Archivo: {{ data.metadata.archivo }} — Procesado: {{ data.metadata.procesado }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user