Integración Shopify → SAP Business One (snobbycl y on-chile)
Pipeline de integración de órdenes de Shopify a SAP Service Layer:
Invoice de reserva, pago con tarjeta o transferencia por transacción,
solicitud de traslado, traslado de stock y entrega.
- Invoice igual a lo cobrado; fulfillments limitados a lo facturado
- Estados terminales integrated, timeout, ignored y cancelled
- Idempotencia contra SAP: adopta documentos existentes en vez de duplicar
- Reintentos de red en el cliente de Shopify
- Secretos fuera de los JSON de tienda (${VARIABLE} resuelta desde .env)
- Herramientas de consulta, integración puntual y carga de pruebas
- Documentación en docs/ (flujo, casos especiales, mapeo, configuración)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
901dd8763d
47 files changed
+4709
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
# Copiar a .env y completar. Una pareja de variables por tienda (se referencian como ${VARIABLE} en config/stores/<tienda>.json)
|
||||||
|
SNOBBYCL_SHOPIFY_ACCESS_TOKEN=
|
||||||
|
SNOBBYCL_SAP_PASSWORD=
|
||||||
|
ON_CHILE_SHOPIFY_ACCESS_TOKEN=
|
||||||
|
ON_CHILE_SAP_PASSWORD=
|
||||||
|
|
||||||
|
# Opcional
|
||||||
|
# Zona horaria para mostrar horas (tools/consultar). Si se omite, se usa la del sistema. QA del cliente: America/Santiago
|
||||||
|
# APP_TIMEZONE=America/Santiago
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Entorno y secretos
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Datos y estado local (BD, lock, respaldos)
|
||||||
|
data/*.db*
|
||||||
|
data/*.lock
|
||||||
|
data/*.backup*
|
||||||
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Logs y reportes generados
|
||||||
|
logs/
|
||||||
|
notifications/*.html
|
||||||
|
|
||||||
|
# Volcados y archivos de trabajo (payloads y JSON de Shopify descargados)
|
||||||
|
download/*
|
||||||
|
!download/.gitkeep
|
||||||
|
|
||||||
|
# IDE / sistema
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Integración Shopify → SAP Business One
|
||||||
|
|
||||||
|
Descarga órdenes de tiendas Shopify y crea en SAP Business One (Service Layer) los documentos de venta:
|
||||||
|
Invoice (reserva), pago con tarjeta o transferencia, solicitud de traslado (ITR) y, si se despacha desde una
|
||||||
|
tienda física, traslado de stock y entrega. Una tienda = un archivo en `config/stores/`.
|
||||||
|
Tiendas actuales: `snobbycl` y `on-chile-distributor-prod`.
|
||||||
|
|
||||||
|
## Puesta en marcha
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
|
cp .env.example .env # completar tokens y contraseñas de cada tienda
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Copiar `config/stores/_example.json` a `config/stores/<tienda>.json` y completarlo
|
||||||
|
(`docs/configuracion_tienda.md`). Los secretos van como `${VARIABLE}` y su valor en `.env`.
|
||||||
|
2. Ejecutar el scheduler: `.venv/bin/python main.py` (ventanas y frecuencia en `scheduler.json`; una sola
|
||||||
|
instancia a la vez, con lock).
|
||||||
|
|
||||||
|
Variables opcionales: `LOG_LEVEL`, `APP_TIMEZONE` (zona para mostrar horas, ej. `America/Santiago`).
|
||||||
|
|
||||||
|
## Cómo funciona (resumen)
|
||||||
|
|
||||||
|
1. Cada ciclo baja cabeceras nuevas (desde `max(id en BD, initial_since_id)`) a SQLite (`data/shopify_sap.db`).
|
||||||
|
2. Toma las órdenes no terminales, trae el detalle y las transacciones de pago, y crea los documentos en SAP.
|
||||||
|
Retoma donde quedó si un documento falló, y adopta documentos que ya existan en SAP para no duplicar.
|
||||||
|
3. Estados: `pending`, `not_eligible`, `in_progress`, `error` (se reintentan) e `integrated`, `timeout`,
|
||||||
|
`ignored`, `cancelled` (terminales).
|
||||||
|
|
||||||
|
## Herramientas (`tools/`, ejecutar con `.venv/bin/python -m tools.<nombre>`)
|
||||||
|
|
||||||
|
| Herramienta | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `consultar` | `resumen`, `descargadas`, `integradas`, `errores`, `orden --name "#8064"` (solo lectura) |
|
||||||
|
| `integrar_ordenes` | dry-run o integración (`--apply`) de órdenes puntuales |
|
||||||
|
| `probar_pago` | payload del pago sobre la Invoice real |
|
||||||
|
| `descargar_ordenes_especiales` | guarda en `download/` el JSON de los casos especiales |
|
||||||
|
| `cargar_items_faltantes`, `entrada_mercancia_ordenes` | artículos y stock para pruebas |
|
||||||
|
|
||||||
|
Las herramientas que escriben en SAP son dry-run por defecto y requieren `--apply`.
|
||||||
|
|
||||||
|
## Documentación (`docs/`)
|
||||||
|
|
||||||
|
| Documento | Contenido |
|
||||||
|
|---|---|
|
||||||
|
| `flujo_pipeline.md` | flujo caso por caso y reglas vigentes |
|
||||||
|
| `casos_especiales.md` | casos fuera del flujo normal, con órdenes de ejemplo de cada tienda |
|
||||||
|
| `mapeo_campos.md` | campos de Shopify → SAP por documento |
|
||||||
|
| `configuracion_tienda.md` | guía del archivo de cada tienda, `payment_methods` y secretos |
|
||||||
|
| `esquema_base_datos.md` | tablas y estados de SQLite |
|
||||||
|
| `pendientes.md` | roadmap, decisiones abiertas y pruebas pendientes |
|
||||||
|
| `analisis_tiempos.md` | capacidad y tiempos por orden |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"store_code": "TIENDA1",
|
||||||
|
"shopify": {
|
||||||
|
"api_id": "TODO",
|
||||||
|
"store": "TODO-nombre-de-la-tienda-myshopify",
|
||||||
|
"api_version": "2026-07",
|
||||||
|
"access_token": "${TIENDA1_SHOPIFY_ACCESS_TOKEN}",
|
||||||
|
"page_limit": 50,
|
||||||
|
"initial_since_id": "TODO-id-de-orden-desde-el-que-se-integra (piso del cursor: max(id en BD, este valor))"
|
||||||
|
},
|
||||||
|
"sap_connection": {
|
||||||
|
"url": "https://TODO-servidor:50000/b1s/v1",
|
||||||
|
"company_db": "TODO-SBO_COMPANYDB",
|
||||||
|
"username": "TODO",
|
||||||
|
"password": "${TIENDA1_SAP_PASSWORD}",
|
||||||
|
"timeout": 15,
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"sap": {
|
||||||
|
"default_warehouse_invoice": "TODO-bodega-virtual-de-esta-tienda",
|
||||||
|
"generic_card_code": "TODO-CardCode-generico-si-el-RUT-no-es-valido",
|
||||||
|
"numbering_series": {
|
||||||
|
"INVOICE": null,
|
||||||
|
"INCOMING_PAYMENT": null,
|
||||||
|
"INVENTORY_TRANSFER_REQUEST": null,
|
||||||
|
"STOCK_TRANSFER": null,
|
||||||
|
"DELIVERY": null
|
||||||
|
},
|
||||||
|
"locations": {
|
||||||
|
"TODO-location_id-de-shopify": {
|
||||||
|
"is_store": true,
|
||||||
|
"warehouse_code": "TODO-bodega-SAP-de-esa-tienda-fisica"
|
||||||
|
},
|
||||||
|
"TODO-location_id-del-CD": {
|
||||||
|
"is_store": false,
|
||||||
|
"warehouse_code": "TODO-bodega-SAP-del-CD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payment_methods": {
|
||||||
|
"TODO-nombre-exacto-del-gateway-en-Shopify": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 0,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"TODO-gateway-por-transferencia": {
|
||||||
|
"type": "transfer",
|
||||||
|
"transfer_account": "TODO-cuenta-contable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout_days": 30,
|
||||||
|
"max_orders_per_cycle": 50
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"store_code": "on-chile-distributor-prod",
|
||||||
|
"shopify": {
|
||||||
|
"api_id": "5541a00589b2b1b40afb62433f385891",
|
||||||
|
"store": "on-chile-distributor-prod",
|
||||||
|
"api_version": "2026-07",
|
||||||
|
"access_token": "${ON_CHILE_SHOPIFY_ACCESS_TOKEN}",
|
||||||
|
"page_limit": 50,
|
||||||
|
"initial_since_id": "8103221690695"
|
||||||
|
},
|
||||||
|
"sap_connection": {
|
||||||
|
"url": "https://sap10:50000/b1s/v1",
|
||||||
|
"company_db": "SBODemoCL",
|
||||||
|
"username": "manager",
|
||||||
|
"password": "${ON_CHILE_SAP_PASSWORD}",
|
||||||
|
"timeout": 30,
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"sap": {
|
||||||
|
"default_warehouse_invoice": "9902",
|
||||||
|
"generic_card_code": "C99998",
|
||||||
|
"numbering_series": {
|
||||||
|
"INVOICE": 1,
|
||||||
|
"INCOMING_PAYMENT": 18,
|
||||||
|
"INVENTORY_TRANSFER_REQUEST": 51,
|
||||||
|
"STOCK_TRANSFER": 24,
|
||||||
|
"DELIVERY": 8
|
||||||
|
},
|
||||||
|
"locations": {
|
||||||
|
"102370836807": {
|
||||||
|
"name": "On Parque Arauco",
|
||||||
|
"is_store": true,
|
||||||
|
"warehouse_code": "8803"
|
||||||
|
},
|
||||||
|
"101564121415": {
|
||||||
|
"name": "Geodis",
|
||||||
|
"is_store": false,
|
||||||
|
"warehouse_code": "7702"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payment_methods": {
|
||||||
|
"Transbank Webpay": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 4,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Transbank": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 4,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Checkout Mercado Pago": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 5,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"VentiPay": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 6,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Venti": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 6,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Fintoc": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 7,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Mercado Pago Tarjetas": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 8,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"PayPal": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 9,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Mercado Pago Checkout Pro": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 10,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"reversso": {
|
||||||
|
"type": "transfer",
|
||||||
|
"transfer_account": "1-1-010-20-000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout_days": 60,
|
||||||
|
"max_orders_per_cycle": 50
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"store_code": "snobbycl",
|
||||||
|
"shopify": {
|
||||||
|
"api_id": "34936c643039076a0008433c3283e98a",
|
||||||
|
"store": "snobbycl",
|
||||||
|
"api_version": "2026-07",
|
||||||
|
"access_token": "${SNOBBYCL_SHOPIFY_ACCESS_TOKEN}",
|
||||||
|
"page_limit": 50,
|
||||||
|
"initial_since_id": "7425850048790"
|
||||||
|
},
|
||||||
|
"sap_connection": {
|
||||||
|
"url": "https://sap10:50000/b1s/v1",
|
||||||
|
"company_db": "SBODemoCL",
|
||||||
|
"username": "manager",
|
||||||
|
"password": "${SNOBBYCL_SAP_PASSWORD}",
|
||||||
|
"timeout": 30,
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"sap": {
|
||||||
|
"default_warehouse_invoice": "9901",
|
||||||
|
"generic_card_code": "C99998",
|
||||||
|
"numbering_series": {
|
||||||
|
"INVOICE": 1,
|
||||||
|
"INCOMING_PAYMENT": 18,
|
||||||
|
"INVENTORY_TRANSFER_REQUEST": 51,
|
||||||
|
"STOCK_TRANSFER": 24,
|
||||||
|
"DELIVERY": 8
|
||||||
|
},
|
||||||
|
"locations": {
|
||||||
|
"113688871190": {
|
||||||
|
"name": "Geodis",
|
||||||
|
"is_store": false,
|
||||||
|
"warehouse_code": "7701"
|
||||||
|
},
|
||||||
|
"90870743318": {
|
||||||
|
"name": "Snobby Casa Costanera",
|
||||||
|
"is_store": true,
|
||||||
|
"warehouse_code": "8801"
|
||||||
|
},
|
||||||
|
"115304300822": {
|
||||||
|
"name": "Snobby Cenco Costanera",
|
||||||
|
"is_store": true,
|
||||||
|
"warehouse_code": "8802"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payment_methods": {
|
||||||
|
"Transbank Webpay": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 4,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Checkout Mercado Pago": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 5,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"VentiPay": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 6,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Fintoc": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 7,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Mercado Pago Tarjetas": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 8,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"PayPal": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 9,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"Mercado Pago Checkout Pro": {
|
||||||
|
"type": "credit_card",
|
||||||
|
"credit_card": 10,
|
||||||
|
"split_payment": true,
|
||||||
|
"num_of_payments": 1
|
||||||
|
},
|
||||||
|
"reversso": {
|
||||||
|
"type": "transfer",
|
||||||
|
"transfer_account": "1-1-010-20-000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout_days": 60,
|
||||||
|
"max_orders_per_cycle": 50
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
_env_cargado = False
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""Falta una variable de entorno referenciada en la configuración de una tienda."""
|
||||||
|
|
||||||
|
|
||||||
|
def cargar_env() -> None:
|
||||||
|
"""Carga <raíz>/.env una sola vez (no pisa variables que ya estén definidas en el entorno)."""
|
||||||
|
global _env_cargado
|
||||||
|
if not _env_cargado:
|
||||||
|
load_dotenv(ROOT / ".env", override=False)
|
||||||
|
_env_cargado = True
|
||||||
|
|
||||||
|
|
||||||
|
def _expandir(valor: Any, origen: str) -> Any:
|
||||||
|
if isinstance(valor, dict):
|
||||||
|
return {k: _expandir(v, origen) for k, v in valor.items()}
|
||||||
|
if isinstance(valor, list):
|
||||||
|
return [_expandir(v, origen) for v in valor]
|
||||||
|
if isinstance(valor, str):
|
||||||
|
def reemplazo(m: re.Match) -> str:
|
||||||
|
nombre = m.group(1)
|
||||||
|
if nombre not in os.environ:
|
||||||
|
raise ConfigError(f"{origen}: falta la variable de entorno {nombre} (defínala en .env o en el entorno)")
|
||||||
|
return os.environ[nombre]
|
||||||
|
|
||||||
|
return _PLACEHOLDER.sub(reemplazo, valor)
|
||||||
|
return valor
|
||||||
|
|
||||||
|
|
||||||
|
def cargar_config_tienda(path: Path | str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Lee el JSON de configuración de una tienda. Cualquier texto con el formato
|
||||||
|
${NOMBRE} se reemplaza por la variable de entorno NOMBRE (del entorno o de
|
||||||
|
.env), para no guardar tokens ni contraseñas en el JSON. Los valores
|
||||||
|
escritos directamente siguen funcionando.
|
||||||
|
"""
|
||||||
|
cargar_env()
|
||||||
|
path = Path(path)
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return _expandir(json.load(f), path.name)
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_sap.db"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
store_code TEXT NOT NULL,
|
||||||
|
shopify_order_id INTEGER NOT NULL,
|
||||||
|
order_number TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
payment_gateway_names TEXT,
|
||||||
|
shopify_created_at TEXT NOT NULL,
|
||||||
|
time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||||
|
time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||||
|
financial_status TEXT,
|
||||||
|
fulfillment_status TEXT,
|
||||||
|
card_code TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_error TEXT,
|
||||||
|
UNIQUE(store_code, shopify_order_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_fulfillments (
|
||||||
|
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id),
|
||||||
|
shopify_fulfillment_id INTEGER NOT NULL,
|
||||||
|
location_id TEXT,
|
||||||
|
is_store INTEGER NOT NULL,
|
||||||
|
warehouse_code TEXT,
|
||||||
|
time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||||
|
UNIQUE(order_sys_id, shopify_fulfillment_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_documents (
|
||||||
|
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id),
|
||||||
|
fulfillment_sys_id INTEGER REFERENCES order_fulfillments(sys_id),
|
||||||
|
doc_type TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
required INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
sap_doc_entry INTEGER,
|
||||||
|
sap_doc_num INTEGER,
|
||||||
|
base_doc_entry INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_store ON orders(store_code, shopify_order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fulfillments_order ON order_fulfillments(order_sys_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_order ON order_documents(order_sys_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_status ON order_documents(status);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db_session():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def get_last_shopify_order_id(store_code: str) -> int:
|
||||||
|
"""Id de Shopify más alto ya guardado para esta tienda (0 si no hay ninguna)."""
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(shopify_order_id) AS max_id FROM orders WHERE store_code = ?",
|
||||||
|
(store_code,),
|
||||||
|
).fetchone()
|
||||||
|
return row["max_id"] or 0
|
||||||
|
|
||||||
|
|
||||||
|
def insert_order_header(store_code: str, header: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Inserta la cabecera de una orden nueva. Devuelve True si se insertó,
|
||||||
|
False si ya existía (store_code + shopify_order_id duplicado).
|
||||||
|
"""
|
||||||
|
with db_session() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (
|
||||||
|
store_code, shopify_order_id, order_number, tags,
|
||||||
|
shopify_created_at, financial_status, fulfillment_status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
store_code,
|
||||||
|
header["shopify_order_id"],
|
||||||
|
header.get("order_number"),
|
||||||
|
header.get("tags"),
|
||||||
|
header["shopify_created_at"],
|
||||||
|
header.get("financial_status"),
|
||||||
|
header.get("fulfillment_status"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def mark_orders_timeout(store_code: str, timeout_days: int) -> int:
|
||||||
|
"""
|
||||||
|
Marca como 'timeout' las órdenes de esta tienda que superaron
|
||||||
|
timeout_days desde su creación en Shopify y no llegaron a 'integrated'.
|
||||||
|
Devuelve la cantidad de órdenes marcadas.
|
||||||
|
"""
|
||||||
|
with db_session() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE orders
|
||||||
|
SET status = 'timeout', time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE store_code = ?
|
||||||
|
AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled')
|
||||||
|
AND julianday('now') - julianday(shopify_created_at) > ?
|
||||||
|
""",
|
||||||
|
(store_code, timeout_days),
|
||||||
|
)
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_orders(store_code: str) -> list[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Órdenes de esta tienda que aún no llegaron a un estado terminal.
|
||||||
|
Ordenadas por time_update ASC (la que hace más tiempo no se toca,
|
||||||
|
primero): así una orden que falla repetido no bloquea el cupo por
|
||||||
|
ciclo para siempre, porque cada intento le actualiza time_update y la
|
||||||
|
manda al final de la fila hasta la próxima vuelta.
|
||||||
|
"""
|
||||||
|
with db_session() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM orders
|
||||||
|
WHERE store_code = ?
|
||||||
|
AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled')
|
||||||
|
ORDER BY time_update ASC, shopify_order_id ASC
|
||||||
|
""",
|
||||||
|
(store_code,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def get_order_sys_id(store_code: str, shopify_order_id: int) -> Optional[int]:
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT sys_id FROM orders WHERE store_code = ? AND shopify_order_id = ?",
|
||||||
|
(store_code, shopify_order_id),
|
||||||
|
).fetchone()
|
||||||
|
return row["sys_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_order_document(
|
||||||
|
order_sys_id: int,
|
||||||
|
doc_type: str,
|
||||||
|
sequence: int,
|
||||||
|
fulfillment_sys_id: Optional[int] = None,
|
||||||
|
required: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Devuelve el sys_id de la fila order_documents para (order, fulfillment,
|
||||||
|
doc_type), creándola en estado 'pending' si todavía no existe.
|
||||||
|
"""
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT sys_id FROM order_documents
|
||||||
|
WHERE order_sys_id = ?
|
||||||
|
AND doc_type = ?
|
||||||
|
AND fulfillment_sys_id IS ?
|
||||||
|
""",
|
||||||
|
(order_sys_id, doc_type, fulfillment_sys_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["sys_id"]
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO order_documents (
|
||||||
|
order_sys_id, fulfillment_sys_id, doc_type, sequence, required
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(order_sys_id, fulfillment_sys_id, doc_type, sequence, int(required)),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def mark_document_error(document_sys_id: int, error_message: str) -> None:
|
||||||
|
with db_session() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE order_documents
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = ?,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE sys_id = ?
|
||||||
|
""",
|
||||||
|
(error_message, document_sys_id),
|
||||||
|
)
|
||||||
|
order_sys_id = conn.execute(
|
||||||
|
"SELECT order_sys_id FROM order_documents WHERE sys_id = ?", (document_sys_id,)
|
||||||
|
).fetchone()["order_sys_id"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE orders
|
||||||
|
SET status = 'error', last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE sys_id = ?
|
||||||
|
""",
|
||||||
|
(error_message, order_sys_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_document_created(document_sys_id: int, sap_doc_entry: int, sap_doc_num: int) -> None:
|
||||||
|
with db_session() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE order_documents
|
||||||
|
SET status = 'created',
|
||||||
|
sap_doc_entry = ?,
|
||||||
|
sap_doc_num = ?,
|
||||||
|
error_message = NULL,
|
||||||
|
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE sys_id = ?
|
||||||
|
""",
|
||||||
|
(sap_doc_entry, sap_doc_num, document_sys_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_doc_entries(order_sys_id: int, doc_type: str) -> list[int]:
|
||||||
|
"""DocEntry de SAP ya registrados para esta orden y tipo de documento."""
|
||||||
|
with db_session() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT sap_doc_entry FROM order_documents WHERE order_sys_id = ? AND doc_type = ? AND sap_doc_entry IS NOT NULL",
|
||||||
|
(order_sys_id, doc_type),
|
||||||
|
).fetchall()
|
||||||
|
return [r["sap_doc_entry"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_document(document_sys_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM order_documents WHERE sys_id = ?", (document_sys_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def set_order_status(order_sys_id: int, status: str, last_error: Optional[str] = None) -> None:
|
||||||
|
with db_session() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE orders
|
||||||
|
SET status = ?, last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE sys_id = ?
|
||||||
|
""",
|
||||||
|
(status, last_error, order_sys_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_order_card_code(order_sys_id: int, card_code: str) -> None:
|
||||||
|
with db_session() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE orders SET card_code = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now') WHERE sys_id = ?",
|
||||||
|
(card_code, order_sys_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_order_snapshot(order_sys_id: int, order: Dict[str, Any]) -> None:
|
||||||
|
"""Refresca los campos de Shopify que pueden cambiar entre corridas."""
|
||||||
|
gateways = order.get("payment_gateway_names") or []
|
||||||
|
with db_session() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE orders
|
||||||
|
SET financial_status = ?,
|
||||||
|
fulfillment_status = ?,
|
||||||
|
tags = ?,
|
||||||
|
payment_gateway_names = ?,
|
||||||
|
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||||
|
WHERE sys_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
order.get("financial_status"),
|
||||||
|
order.get("fulfillment_status"),
|
||||||
|
order.get("tags"),
|
||||||
|
" + ".join(gateways) if gateways else None,
|
||||||
|
order_sys_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def ensure_order_fulfillment(
|
||||||
|
order_sys_id: int,
|
||||||
|
shopify_fulfillment_id: int,
|
||||||
|
location_id: str,
|
||||||
|
is_store: bool,
|
||||||
|
warehouse_code: str,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Devuelve el sys_id de order_fulfillments para este fulfillment,
|
||||||
|
creándolo si todavía no existe (una orden puede recibir fulfillments
|
||||||
|
nuevos en corridas posteriores).
|
||||||
|
"""
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT sys_id FROM order_fulfillments WHERE order_sys_id = ? AND shopify_fulfillment_id = ?",
|
||||||
|
(order_sys_id, shopify_fulfillment_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["sys_id"]
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO order_fulfillments (
|
||||||
|
order_sys_id, shopify_fulfillment_id, location_id, is_store, warehouse_code
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(order_sys_id, shopify_fulfillment_id, location_id, int(is_store), warehouse_code),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class SAPServiceLayerError(Exception):
|
||||||
|
def __init__(self, status_code: int, detail: str, code: int = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
self.code = code
|
||||||
|
super().__init__(f"[{status_code}] {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
class ShopifyAPIError(Exception):
|
||||||
|
def __init__(self, status_code: int, detail: str):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(f"[{status_code}] {detail}")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from filelock import FileLock, Timeout
|
||||||
|
|
||||||
|
LOCK_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_runrun.lock"
|
||||||
|
|
||||||
|
|
||||||
|
class SingleInstanceError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_single_instance_lock() -> FileLock:
|
||||||
|
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock = FileLock(str(LOCK_PATH), timeout=0)
|
||||||
|
try:
|
||||||
|
lock.acquire()
|
||||||
|
except Timeout:
|
||||||
|
raise SingleInstanceError(
|
||||||
|
f"Ya existe una instancia en ejecución (lock: {LOCK_PATH})"
|
||||||
|
)
|
||||||
|
return lock
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import contextvars
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||||
|
LOG_FILE = LOG_DIR / "shopify-sap.log"
|
||||||
|
EVENTS_FILE = LOG_DIR / "events.jsonl"
|
||||||
|
_LOG_CONFIGURED = False
|
||||||
|
|
||||||
|
_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[])
|
||||||
|
_order_ctx: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar("order_ctx", default=None)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Contexto de orden: taggea automáticamente los logs dentro del bloque con
|
||||||
|
# store_code/shopify_order_id, sin tener que repetirlos en cada llamada.
|
||||||
|
@contextmanager
|
||||||
|
def order_context(store_code: str, shopify_order_id: int):
|
||||||
|
token = _order_ctx.set({"store_code": store_code, "shopify_order_id": shopify_order_id})
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_order_ctx.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
class TreeFormatter(logging.Formatter):
|
||||||
|
def format(self, record):
|
||||||
|
ctx = _order_ctx.get()
|
||||||
|
tag = f"[{ctx['store_code']}#{ctx['shopify_order_id']}] " if ctx else ""
|
||||||
|
return f"{self.formatTime(record)} ─── {tag}{record.getMessage()}"
|
||||||
|
|
||||||
|
def formatTime(self, record, datefmt=None):
|
||||||
|
return 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="shopify-sap"):
|
||||||
|
setup_logging()
|
||||||
|
return TaskLogger(logging.getLogger(name))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Log de eventos de negocio (JSON Lines), separado del log técnico en árbol.
|
||||||
|
# Pensado para alimentar más adelante un visor de eventos de integración.
|
||||||
|
_events_logger: Optional[logging.Logger] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_events_logger() -> logging.Logger:
|
||||||
|
global _events_logger
|
||||||
|
if _events_logger is not None:
|
||||||
|
return _events_logger
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
handler = TimedRotatingFileHandler(
|
||||||
|
EVENTS_FILE,
|
||||||
|
when="midnight",
|
||||||
|
interval=1,
|
||||||
|
backupCount=30,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
||||||
|
logger = logging.getLogger("shopify-sap-events")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
_events_logger = logger
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(event: str, level: str = "info", message: str = "", **fields) -> None:
|
||||||
|
ctx = _order_ctx.get() or {}
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"level": level.upper(),
|
||||||
|
"event": event,
|
||||||
|
"message": message,
|
||||||
|
**ctx,
|
||||||
|
**fields,
|
||||||
|
}
|
||||||
|
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
|
_get_events_logger().log(log_level, json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import html
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from core.db import db_session
|
||||||
|
|
||||||
|
REPORTS_DIR = Path(__file__).resolve().parent.parent / "notifications"
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_errores_pendientes(store_code: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
query = """
|
||||||
|
SELECT o.store_code, o.shopify_order_id, o.order_number,
|
||||||
|
d.doc_type, d.attempts, d.error_message, d.time_update
|
||||||
|
FROM order_documents d
|
||||||
|
JOIN orders o ON o.sys_id = d.order_sys_id
|
||||||
|
WHERE d.status = 'error'
|
||||||
|
"""
|
||||||
|
params: tuple = ()
|
||||||
|
if store_code:
|
||||||
|
query += " AND o.store_code = ?"
|
||||||
|
params = (store_code,)
|
||||||
|
query += " ORDER BY d.time_update DESC"
|
||||||
|
|
||||||
|
with db_session() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def generar_reporte_errores_html(store_code: Optional[str] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Genera un HTML (tabla) con los order_documents en estado 'error' y lo
|
||||||
|
guarda en notifications/. Por ahora solo se guarda en disco; el envío
|
||||||
|
(mail/mensaje) se implementa más adelante.
|
||||||
|
"""
|
||||||
|
errores = obtener_errores_pendientes(store_code)
|
||||||
|
|
||||||
|
filas = "\n".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(e['store_code'])}</td>"
|
||||||
|
f"<td>{html.escape(e['order_number'] or '')}</td>"
|
||||||
|
f"<td>{e['shopify_order_id']}</td>"
|
||||||
|
f"<td>{html.escape(e['doc_type'])}</td>"
|
||||||
|
f"<td>{e['attempts']}</td>"
|
||||||
|
f"<td>{html.escape(e['error_message'] or '')}</td>"
|
||||||
|
f"<td>{html.escape(e['time_update'])}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for e in errores
|
||||||
|
)
|
||||||
|
|
||||||
|
contenido = f"""<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Errores de integración Shopify -> SAP</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; font-size: 14px; color: #222; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; }}
|
||||||
|
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; vertical-align: top; }}
|
||||||
|
th {{ background-color: #f2f2f2; }}
|
||||||
|
tr:nth-child(even) {{ background-color: #fafafa; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Errores de integración Shopify -> SAP</h2>
|
||||||
|
<p>Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Total: {len(errores)}</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Tienda</th><th>Orden</th><th>Shopify ID</th><th>Documento</th><th>Intentos</th><th>Error</th><th>Última actualización</th>
|
||||||
|
</tr>
|
||||||
|
{filas}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
archivo = REPORTS_DIR / f"errores_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
||||||
|
archivo.write_text(contenido, encoding="utf-8")
|
||||||
|
return archivo
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SAPServiceLayerClient:
|
||||||
|
"""
|
||||||
|
Cliente de SAP B1 Service Layer para una conexión (URL + CompanyDB)
|
||||||
|
específica. Cada tienda Shopify puede apuntar a una base SAP distinta,
|
||||||
|
por eso la sesión (B1SESSION) vive por instancia y no en un estado
|
||||||
|
global del módulo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
company_db: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
verify_ssl: bool = False,
|
||||||
|
):
|
||||||
|
self.url = url.rstrip("/")
|
||||||
|
self.company_db = company_db
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.timeout = timeout
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self._headers: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_store_config(cls, sap_connection: Dict[str, Any]) -> "SAPServiceLayerClient":
|
||||||
|
"""
|
||||||
|
Construye el cliente a partir del bloque "sap_connection" del JSON
|
||||||
|
de configuración de una tienda.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
url=sap_connection["url"],
|
||||||
|
company_db=sap_connection["company_db"],
|
||||||
|
username=sap_connection["username"],
|
||||||
|
password=sap_connection["password"],
|
||||||
|
timeout=float(sap_connection.get("timeout", 15.0)),
|
||||||
|
verify_ssl=bool(sap_connection.get("verify_ssl", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def _log_response(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
response: requests.Response,
|
||||||
|
request_data: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
extra = {
|
||||||
|
"http_method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"company_db": self.company_db,
|
||||||
|
}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
error_detail = response.json()
|
||||||
|
respuesta = json.dumps(error_detail, indent=2)[:500]
|
||||||
|
except Exception:
|
||||||
|
respuesta = response.text[:500]
|
||||||
|
logger.error(
|
||||||
|
f"[{self.company_db}] [{response.status_code}] {method} {endpoint} - "
|
||||||
|
f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - "
|
||||||
|
f"Respuesta: {respuesta}",
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"[{self.company_db}] [{response.status_code}] {method} {endpoint}", extra=extra)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def ensure_session(self, force_refresh: bool = False) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Verifica si existe una sesión (B1SESSION) para esta conexión.
|
||||||
|
Si no existe o force_refresh es True, hace Login.
|
||||||
|
"""
|
||||||
|
if self._headers and not force_refresh:
|
||||||
|
return self._headers
|
||||||
|
|
||||||
|
logger.info(f"[{self.company_db}] Iniciando sesión en SAP Service Layer...")
|
||||||
|
auth_url = f"{self.url}/Login"
|
||||||
|
payload = {
|
||||||
|
"CompanyDB": self.company_db,
|
||||||
|
"UserName": self.username,
|
||||||
|
"Password": self.password,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
auth_url,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
f"[{self.company_db}] Error crítico: no se pudo iniciar sesión en SAP. "
|
||||||
|
f"Status: {response.status_code}"
|
||||||
|
)
|
||||||
|
self._headers = None
|
||||||
|
raise SAPServiceLayerError(response.status_code, "Conexión fallida con el servidor SAP")
|
||||||
|
|
||||||
|
sap_session = response.json()
|
||||||
|
b1_session_id = sap_session.get("SessionId")
|
||||||
|
route_id = response.cookies.get("ROUTEID")
|
||||||
|
|
||||||
|
headers = {"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"}
|
||||||
|
self._headers = headers
|
||||||
|
logger.info(f"[{self.company_db}] Sesión establecida. B1SESSION: {b1_session_id[:10]}...")
|
||||||
|
return headers
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error(f"[{self.company_db}] Error de conexión al autenticar contra SAP: {exc}")
|
||||||
|
self._headers = None
|
||||||
|
raise SAPServiceLayerError(500, f"Error al conectar con SAP Service Layer: {exc}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
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,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""
|
||||||
|
Realiza una llamada a SAP B1 Service Layer, manejando autenticación
|
||||||
|
transparente con sesión única (por instancia) y reintento en 401.
|
||||||
|
"""
|
||||||
|
if not method or not endpoint_path:
|
||||||
|
raise ValueError("method y endpoint_path son requeridos")
|
||||||
|
|
||||||
|
method = method.upper()
|
||||||
|
headers = self.ensure_session()
|
||||||
|
url_destino = f"{self.url}/{endpoint_path}"
|
||||||
|
|
||||||
|
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||||
|
req_headers.update(headers)
|
||||||
|
if extra_headers:
|
||||||
|
req_headers.update(extra_headers)
|
||||||
|
|
||||||
|
log_extra = {"http_method": method, "endpoint": endpoint_path, "company_db": self.company_db}
|
||||||
|
logger.info(f"[{self.company_db}] [{method}] {endpoint_path}", extra=log_extra)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url_destino,
|
||||||
|
json=json_data,
|
||||||
|
params=params,
|
||||||
|
headers=req_headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"[{self.company_db}] Timeout ({self.timeout}s) en {endpoint_path}", extra=log_extra)
|
||||||
|
raise ConnectionError(f"Timeout al conectar con {endpoint_path}")
|
||||||
|
except requests.exceptions.ConnectionError as exc:
|
||||||
|
logger.error(f"[{self.company_db}] Error de conexión: {exc}", extra=log_extra)
|
||||||
|
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error(f"[{self.company_db}] Error en solicitud: {exc}", extra=log_extra)
|
||||||
|
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.warning(
|
||||||
|
f"[{self.company_db}] Sesión expirada (401). Reautenticando e intentando una sola vez...",
|
||||||
|
extra=log_extra,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers = self.ensure_session(force_refresh=True)
|
||||||
|
req_headers.update(headers)
|
||||||
|
|
||||||
|
logger.info(f"[{self.company_db}] Reintentando solicitud [RETRY]", extra=log_extra)
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url_destino,
|
||||||
|
json=json_data,
|
||||||
|
params=params,
|
||||||
|
headers=req_headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[{self.company_db}] Error en reintento: {exc}", extra=log_extra)
|
||||||
|
raise ConnectionError(f"Error en reintento tras reautenticación: {exc}")
|
||||||
|
|
||||||
|
self._log_response(method, endpoint_path, response, json_data)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_msg = response.text
|
||||||
|
error_code = None
|
||||||
|
try:
|
||||||
|
err = response.json()
|
||||||
|
error_msg = err.get("error", {}).get("message", {}).get("value", error_msg)
|
||||||
|
error_code = err.get("error", {}).get("code")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise SAPServiceLayerError(response.status_code, error_msg, code=error_code)
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import truststore
|
||||||
|
|
||||||
|
# Verifica certificados usando el almacén de confianza del sistema operativo
|
||||||
|
# en vez del bundle estático de certifi. Necesario detrás de firewalls que
|
||||||
|
# hacen inspección TLS (ej. Fortinet) y, en el servicio Windows, para
|
||||||
|
# confiar automáticamente en los CA que administre IT.
|
||||||
|
truststore.inject_into_ssl()
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.errors import ShopifyAPIError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NETWORK_RETRIES = 4 # intentos totales ante fallos de red
|
||||||
|
NETWORK_BACKOFF = 2 # segundos; espera = backoff * nº de intento
|
||||||
|
|
||||||
|
ORDER_HEADER_FIELDS ="created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags"
|
||||||
|
|
||||||
|
|
||||||
|
class ShopifyClient:
|
||||||
|
"""
|
||||||
|
Cliente de la Admin API de Shopify para una tienda específica.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: str,
|
||||||
|
api_id: str,
|
||||||
|
access_token: str,
|
||||||
|
api_version: str,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
):
|
||||||
|
self.store = store
|
||||||
|
self.base_url = f"https://{store}.myshopify.com/admin/api/{api_version}"
|
||||||
|
self.auth = (api_id, access_token)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_store_config(cls, shopify_config: Dict[str, Any]) -> "ShopifyClient":
|
||||||
|
return cls(
|
||||||
|
store=shopify_config["store"],
|
||||||
|
api_id=shopify_config["api_id"],
|
||||||
|
access_token=shopify_config["access_token"],
|
||||||
|
api_version=shopify_config["api_version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def _request(self, url: str, params: Dict[str, Any]) -> requests.Response:
|
||||||
|
"""
|
||||||
|
GET con reintentos ante fallos transitorios de red (SSL EOF, reset de
|
||||||
|
conexión, timeout), con espera creciente. Los errores HTTP (4xx/5xx) no
|
||||||
|
se reintentan aquí: los maneja _get.
|
||||||
|
"""
|
||||||
|
for intento in range(1, NETWORK_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
return requests.get(url, params=params, auth=self.auth, timeout=self.timeout)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
if intento == NETWORK_RETRIES:
|
||||||
|
raise
|
||||||
|
espera = NETWORK_BACKOFF * intento
|
||||||
|
logger.warning(f"[{self.store}] Fallo de red ({type(exc).__name__}), reintento {intento}/{NETWORK_RETRIES - 1} en {espera}s")
|
||||||
|
time.sleep(espera)
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Dict[str, Any]) -> requests.Response:
|
||||||
|
url = f"{self.base_url}/{path}"
|
||||||
|
|
||||||
|
response = self._request(url, params)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = float(response.headers.get("Retry-After", 2))
|
||||||
|
logger.warning(f"[{self.store}] Rate limit alcanzado, reintentando en {retry_after}s")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
response = self._request(url, params)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise ShopifyAPIError(response.status_code, response.text[:500])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def obtener_cabeceras_ordenes(self, since_id: int = 0, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Descarga cabeceras de órdenes (status=any) con id mayor a since_id,
|
||||||
|
ordenadas ascendente por id (comportamiento por defecto de Shopify).
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"fields": ORDER_HEADER_FIELDS,
|
||||||
|
"status": "any",
|
||||||
|
"since_id": since_id,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"[{self.store}] GET orders.json since_id={since_id} limit={limit}")
|
||||||
|
response = self._get("orders.json", params)
|
||||||
|
return response.json().get("orders", [])
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def obtener_orden(self, shopify_order_id: int) -> Dict[str, Any]:
|
||||||
|
"""Descarga el detalle completo (cabecera + line_items) de una orden."""
|
||||||
|
logger.info(f"[{self.store}] GET orders/{shopify_order_id}.json")
|
||||||
|
response = self._get(f"orders/{shopify_order_id}.json", params={})
|
||||||
|
return response.json().get("order", {})
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
def obtener_transacciones(self, shopify_order_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Transacciones de pago de la orden (monto, gateway, estado, tarjeta)."""
|
||||||
|
logger.info(f"[{self.store}] GET orders/{shopify_order_id}/transactions.json")
|
||||||
|
response = self._get(f"orders/{shopify_order_id}/transactions.json", params={})
|
||||||
|
return response.json().get("transactions", [])
|
||||||
Whitespace-only changes.
@@ -0,0 +1,84 @@
|
|||||||
|
# Análisis de tiempos del pipeline Shopify → SAP
|
||||||
|
|
||||||
|
Fecha de medición: 2026-09-25. Entorno: SAP Service Layer (`SBODemoCL`) en la misma
|
||||||
|
red/VPN, Shopify Admin API por internet.
|
||||||
|
|
||||||
|
## Latencias individuales medidas (llamadas aisladas)
|
||||||
|
|
||||||
|
| Llamada | Tiempo promedio |
|
||||||
|
|---|---|
|
||||||
|
| SAP GET (misma red/VPN) | ~0.056 s |
|
||||||
|
| Shopify GET orden completa | ~0.469 s |
|
||||||
|
|
||||||
|
## Ciclo real completo (20 órdenes, `snobbycl`, sin RUT)
|
||||||
|
|
||||||
|
| Métrica | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Duración total | 87 s |
|
||||||
|
| Promedio por orden | ~4.3 s |
|
||||||
|
| Intervalo configurado ("rapido") | 600 s |
|
||||||
|
| Margen usado | ~14% del ciclo |
|
||||||
|
|
||||||
|
Esta medición corresponde a órdenes **sin RUT** (ninguna llamó a `BusinessPartners`),
|
||||||
|
caso "tienda" completo: Invoice → Payment → InventoryTransferRequest → StockTransfer
|
||||||
|
→ Delivery, con sus referencias cruzadas.
|
||||||
|
|
||||||
|
## Ruta más larga: tienda + RUT nuevo (creación de BusinessPartner)
|
||||||
|
|
||||||
|
16 llamadas en total:
|
||||||
|
|
||||||
|
| # | Servicio | Llamada | Tiempo aprox. |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Shopify | GET `orders/{id}.json` | ~0.47 s |
|
||||||
|
| 2 | SAP | GET `BusinessPartners` (buscar por RUT) | ~0.06 s |
|
||||||
|
| 3 | SAP | POST `BusinessPartners` (crear, RUT no existía) | ~0.40 s |
|
||||||
|
| 4 | SAP | POST `Invoices` | ~0.35 s |
|
||||||
|
| 5 | SAP | POST `IncomingPayments` | ~0.35 s |
|
||||||
|
| 6 | SAP | POST `InventoryTransferRequests` | ~0.35 s |
|
||||||
|
| 7 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
|
||||||
|
| 8 | SAP | PATCH `Invoices` (agregar ref. ITR) | ~0.35 s |
|
||||||
|
| 9 | SAP | GET `InventoryTransferRequests` (traer líneas completas) | ~0.06 s |
|
||||||
|
| 10 | SAP | POST `StockTransfers` | ~0.35 s |
|
||||||
|
| 11 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
|
||||||
|
| 12 | SAP | PATCH `Invoices` (agregar ref. StockTransfer) | ~0.35 s |
|
||||||
|
| 13 | SAP | GET `StockTransfers` (traer líneas completas) | ~0.06 s |
|
||||||
|
| 14 | SAP | POST `DeliveryNotes` | ~0.35 s |
|
||||||
|
| 15 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
|
||||||
|
| 16 | SAP | PATCH `Invoices` (agregar ref. Delivery) | ~0.35 s |
|
||||||
|
|
||||||
|
### Subtotales
|
||||||
|
|
||||||
|
| Tipo | Cantidad | Tiempo total |
|
||||||
|
|---|---|---|
|
||||||
|
| Shopify GET | 1 | ~0.47 s |
|
||||||
|
| SAP GET | 6 | ~0.36 s |
|
||||||
|
| SAP POST | 6 | ~2.15 s |
|
||||||
|
| SAP PATCH | 3 | ~1.05 s |
|
||||||
|
| **Total** | **16** | **~4.0-4.6 s** |
|
||||||
|
|
||||||
|
Coincide con lo medido en vivo (~4.3 s/orden en `snobbycl` sin RUT — a esa ruta le
|
||||||
|
faltan las llamadas 2 y 3 de la tabla, por eso da un poco menos que esta ruta
|
||||||
|
completa con creación de BusinessPartner).
|
||||||
|
|
||||||
|
Caso CD (solo ITR, sin StockTransfer ni Delivery, sin RUT): ~5 llamadas, bastante
|
||||||
|
más rápido que el caso tienda completo.
|
||||||
|
|
||||||
|
## Capacidad estimada por ciclo
|
||||||
|
|
||||||
|
Con ~4.3-4.6 s/orden real, dejando colchón de seguridad (~70-80% del intervalo
|
||||||
|
usado, el resto para variabilidad de red, reintentos por 401, rate limit de
|
||||||
|
Shopify):
|
||||||
|
|
||||||
|
| Modo | Intervalo | Teórico máx. | Recomendado con margen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| rapido | 600 s | ~139 órdenes | ~95-110 |
|
||||||
|
| full | 1800 s | ~418 órdenes | ~290-330 |
|
||||||
|
|
||||||
|
## Configuración aplicada
|
||||||
|
|
||||||
|
`max_orders_per_cycle = 50` para ambas tiendas (`snobbycl` y
|
||||||
|
`on-chile-distributor-prod`), muy por debajo de la capacidad real medida —
|
||||||
|
deja margen amplio para picos de tráfico o llamadas más lentas de lo normal.
|
||||||
|
|
||||||
|
Con 50 órdenes/ciclo y ~4.6 s/orden en el peor caso (todas con RUT nuevo),
|
||||||
|
un ciclo tardaría ~230 s, menos del 40% del intervalo "rapido" de 600 s.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Casos especiales de integración (Shopify → SAP)
|
||||||
|
|
||||||
|
Casos que se salen del flujo normal (`docs/flujo_pipeline.md`): cómo se detectan, qué hace el pipeline y **una orden
|
||||||
|
real de ejemplo por tienda**. "sin caso" significa que no se encontró un ejemplo real en esa tienda (no que no pueda ocurrir).
|
||||||
|
Los JSON de Shopify de la mayoría de los ejemplos están en `download/` (`shopify_#<name>.json` = orden,
|
||||||
|
`shopify_transactions_#<name>.json` = transacciones).
|
||||||
|
|
||||||
|
Tiendas: **snobbycl** (órdenes `#NNNN`) y **on-chile-distributor-prod** (órdenes `#CLNNNN`).
|
||||||
|
|
||||||
|
## Regla general de montos
|
||||||
|
|
||||||
|
La **Invoice siempre iguala lo realmente cobrado** en Shopify (suma de las transacciones efectivas).
|
||||||
|
Las líneas se arman en `domain/invoice.py::_lineas_por_cobrado`:
|
||||||
|
|
||||||
|
1. Si el total de las líneas originales (`quantity`) coincide con lo cobrado → se usan esas.
|
||||||
|
2. Si no, y el total de las líneas vigentes (`current_quantity`) coincide → se usan esas.
|
||||||
|
3. Si ninguna coincide → se usan las originales y el control de suma del pago deja la orden en `error`
|
||||||
|
(no se crea el pago).
|
||||||
|
|
||||||
|
Transacciones efectivas (`domain/payment.py::transacciones_efectivas`): solo `status = success` y `kind`
|
||||||
|
`sale` o `capture`. Si una `authorization` tiene su `capture` (`parent_id`), cuenta solo la `capture`.
|
||||||
|
Se ignoran `failure`, `pending`, `refund` y `void`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Pagos
|
||||||
|
|
||||||
|
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| A1 | Venta normal | una `sale` exitosa = total de la orden | Invoice + pago + ITR (+ ST + Delivery si es tienda) | #8064 | #CL1467 |
|
||||||
|
| A2 | Intentos fallidos o pendientes antes del pago exitoso | varias transacciones, solo una `success`. `payment_gateway_names` lista todos los intentos y **no** indica quién pagó | solo la exitosa suma | #7880 (Transbank `failure` → Fintoc ok), #7903 (Mercado Pago `pending` → Transbank ok), #8063 (4 intentos, uno con status `error`, y 1 éxito) | #CL9958, #CL9994, #CL10041 (36 en septiembre) |
|
||||||
|
| A3 | `authorization` + `capture` (reversso) | dos `success`, la `capture` con `parent_id` = la `authorization` | solo cuenta la `capture`; reversso va como transferencia (`1-1-010-20-000`) | #7902 | #CL9948, #CL9950, #CL9969 (46 en septiembre) |
|
||||||
|
| A4 | Pago dividido real | dos `sale` `success` de gateways distintos | una línea de `PaymentCreditCards` por transacción, cada una con su tarjeta | sin caso | #CL10236 (Fintoc 152.991 + Transbank 54.000), #CL10594, #CL10967; #CL10947 (Transbank + Transbank Webpay, misma tarjeta SAP 4 → dos líneas) |
|
||||||
|
| A5 | Gateway con nombre distinto al configurado | el `gateway` no coincide con la clave de `sap.payment_methods` (comparación exacta) | agregar las variantes al config con la misma tarjeta | sin caso (sus nombres coinciden) | `Venti` (#CL9933, #CL10054) y `Transbank` (#CL10236), equivalentes a `VentiPay` y `Transbank Webpay` |
|
||||||
|
| A6 | Gateway sin configurar | no está en `sap.payment_methods`, o sin `credit_card`, o `type` inválido | `PaymentConfigError`: el documento de pago queda en `error` con el mensaje y **no se crea el pago** (la Invoice ya existe y se retoma después) | prueba sintética con un gateway inexistente | prueba sintética |
|
||||||
|
| A7 | `VoucherNum` | SAP rechaza más de 20 caracteres (`Value too long in property 'VoucherNum'`) | `VoucherNum` = `name` de la orden (máx. 20) | — | #CL1467 (falló con el `payment_id` de 25 caracteres) |
|
||||||
|
|
||||||
|
Datos fijos de las tarjetas (`domain/payment.py`, provisorios): últimos dígitos `1234`, vencimiento `2030-12-31`,
|
||||||
|
`CreditType cr_Regular`; `SplitPayments` y `NumOfPayments` según el config de cada medio.
|
||||||
|
|
||||||
|
## B. Líneas canceladas, devueltas o reembolsadas
|
||||||
|
|
||||||
|
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| B1 | Línea cancelada **antes** del pago | `refund_line_items` con `restock_type = cancel`; cobrado = total neto (`current_total_price`) | la Invoice usa las líneas vigentes | #7998 (4 unidades, cobrado 339.980; Invoice original DocNum 460 anulada y reintegrada como DocNum 516) | no detectado en septiembre |
|
||||||
|
| B2 | Línea cancelada con dinero reembolsado **después** de cobrar el total | `cancel` + transacción `refund` exitosa; cobrado = total original | la Invoice usa las líneas **originales** (el reembolso es un tratamiento interno de comercial) | sin caso | #CL10072 (cobrado 305.973, reembolsado 71.991) |
|
||||||
|
| B3 | Línea devuelta (`return`) **sin cobro** | `restock_type = return`, sin transacción `refund`, cobrado = total vigente | la Invoice usa las líneas vigentes y `fulfillments_facturados` limita los fulfillments a lo facturado: no se hace ITR, ST ni Delivery de la unidad devuelta (el Delivery fallaría con `KeyError` al no existir la línea base) | sin caso | 15 órdenes, entre ellas #CL10144, #CL10764, #CL10903 |
|
||||||
|
| B4 | Despachada, pagada y **luego reembolsada** (total o parcial) | `financial_status` `refunded` o `partially_refunded`, con fulfillments y sin `cancelled_at` | se integra completa como venta; el reembolso no se refleja en SAP (`ESTADOS_FINANCIEROS_FACTURABLES`) | #7954, #7963, #7978, #7983, #7987, #8020, #8029 (parcial) | #CL9947, #CL9968, #CL9994, #CL9998, #CL10039, #CL10047 (23 en septiembre, aún sin integrar) |
|
||||||
|
| B5 | Reembolso de dinero pendiente, sin líneas | `refund` en `pending` y `refunds` sin `refund_line_items`, total vigente = cobrado | no afecta Invoice ni pago: se integra normal | #8009 | sin caso |
|
||||||
|
| B6 | Pedido de prueba | decisión manual | `status = ignored` (terminal) | #8019 | sin caso |
|
||||||
|
|
||||||
|
## C. Estado de la orden en Shopify
|
||||||
|
|
||||||
|
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| C1 | Orden **anulada** | `cancelled_at` no vacío, aunque siga `paid` | `status = cancelled` (terminal) con fecha y `cancel_reason` en `last_error`; evento `order_cancelled`; nunca se crean documentos | #7893, #7985, #8003, #7862 (14 en total) | #CL9983, #CL10015, #CL10161 (22 en septiembre) |
|
||||||
|
| C2 | Pago pendiente o no pagada | `financial_status` fuera de `paid`/`partially_refunded`/`refunded` (ej. `pending`, `voided`) | `not_eligible`; se reevalúa en cada ciclo | sin caso vigente (#8010 y #8021 eran `voided` y están anuladas) | #CL10283, #CL10733, #CL10828 (`pending`) |
|
||||||
|
| C3 | Pagada y sin despachar | sin `fulfillments` | `not_eligible` hasta que se despache; al despacharse se integra sola | #8055 | #CL9951, #CL10006 (73 en septiembre) |
|
||||||
|
| C4 | Costo de envío mayor que cero | `total_shipping_price_set > 0` | `error`: el mapeo del envío no está soportado | sin caso | #CL10169, #CL1452, #CL1453 |
|
||||||
|
| C5 | Orden sin líneas vigentes | todas las líneas canceladas | `not_eligible` ("Orden sin líneas vigentes") | sin caso | sin caso |
|
||||||
|
|
||||||
|
Estados terminales: `integrated`, `timeout`, `ignored`, `cancelled` (ver `docs/esquema_base_datos.md`).
|
||||||
|
|
||||||
|
## D. SAP
|
||||||
|
|
||||||
|
| # | Caso | Síntoma | Tratamiento | snobbycl | on-chile |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| D1 | SKU inexistente | `POST Invoices` 400 `Item number is missing` (`-5009`) | crear los artículos (`tools/cargar_items_faltantes.py`, grupo 108); la orden se reintenta | #8064 (primer intento) | detectado antes de integrar: 874 artículos faltantes en las órdenes de septiembre |
|
||||||
|
| D2 | Sin stock en la bodega de origen | el ITR/transferencia es rechazado | entrada de mercancía (`tools/entrada_mercancia_ordenes.py`) | bodegas `8801`, `8802` | bodegas `8803`, `7702` |
|
||||||
|
| D3 | Anular una Invoice | `Cancellation is not possible; posting date exceeds limit of 10 days`. El **DocNum no es el DocEntry** | reintentar cuando SAP lo permita; anular siempre por DocEntry | #7998 (DocNum 460 = DocEntry 514) | — |
|
||||||
|
| D4 | Documento base cerrado | Delivery: `One of the base documents has already been closed` | `error` en cada ciclo hasta resolverlo en SAP | #7863 (antiguo) | — |
|
||||||
|
| D5 | Cliente con RUT | `note_attributes` con un RUT válido | se busca/crea el BusinessPartner `C<RUT>` (crea clientes reales en SAP) | sin caso (ninguna orden trae RUT) | #CL1467 → `C<RUT>` (ej. `C12345678-5`) (977 de 1.049 elegibles traen RUT) |
|
||||||
|
| D6 | Cliente sin RUT | sin RUT válido en `note_attributes` | cliente genérico `sap.generic_card_code` (`C99998`); no se consultan los metafields del customer | #8064 | #CL9948 |
|
||||||
|
|
||||||
|
## E. Datos, cursor y reprocesos
|
||||||
|
|
||||||
|
| # | Caso | Descripción | Tratamiento | snobbycl | on-chile |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| E1 | Cursor `since_id` | se descarga desde `max(id máximo en BD, initial_since_id)`; `initial_since_id` es un **piso**. Órdenes de prueba con ids altos en la BD hacían saltar el cursor | alinear la BD (insertar cabeceras) o borrar las de prueba | — | #CL10809 y #CL10851 saltaban el cursor a fines de septiembre |
|
||||||
|
| E2 | Orden integrada en SAP sin registro local | si se borra su fila y vuelve a descargarse, se integraría de nuevo | reinsertarla como `ignored`; además la idempotencia (E6) adopta los documentos existentes | — | #CL10809 (Invoice 319, pago 67, ITR 23) |
|
||||||
|
| E3 | Timeout | `timeout_days` (60) desde la creación en Shopify | `timeout` (terminal). Corte operativo de on-chile: 01/09/2026 (`initial_since_id` = 8103221690695) | — | órdenes de septiembre en adelante |
|
||||||
|
| E4 | API de Shopify caída | error 401/403/red | el error se propaga: el ciclo registra "Error procesando tienda" y reintenta en el siguiente. Ya no hay fallback con archivos manuales (se retiró; se usó mientras faltaba el scope `read_orders` de on-chile) | — | — |
|
||||||
|
| E5 | Casos para análisis | el pipeline **no** guarda solo los casos especiales | `tools/descargar_ordenes_especiales.py <store>` los baja a `download/` | 26 órdenes | 55 órdenes |
|
||||||
|
| E6 | Documento ya existente en SAP (idempotencia) | la BD local no registra un documento que sí existe en SAP (BD perdida, copiada o editada) | antes de crear Invoice, ITR, StockTransfer o Delivery se busca por `U_ShopifyOrderId` + `U_ShopifyStore` con las mismas líneas y se **adopta**; solo Invoices vigentes (`CancelStatus = csNo`); una Invoice ya pagada no genera otro pago; evento `documento_adoptado`. **Límite:** no hay respaldo para el pago si la Invoice no figura pagada | #8064, #7998 (adopta la Invoice 516, no la anulada 460) | #CL1467 |
|
||||||
|
| E7 | Corte de red | `SSLEOFError`, reset de conexión, timeout | `ShopifyClient` reintenta hasta 4 veces con espera creciente; los errores HTTP no se reintentan | — | corte SSL al paginar cabeceras |
|
||||||
|
|
||||||
|
## F. No cubierto todavía
|
||||||
|
|
||||||
|
- **Reembolso o anulación posterior a integrar:** `integrated` es terminal, por lo que SAP queda con la venta completa y
|
||||||
|
sin nota de crédito ni devolución. Decisión pendiente con contabilidad (`docs/pendientes.md`).
|
||||||
|
- **Fulfillments vs. Invoice:** `fulfillments_facturados` reparte las cantidades de la Invoice entre los fulfillments en
|
||||||
|
orden; si una misma línea se devolviera parcialmente entre varios fulfillments, el reparto sigue ese orden y no el real.
|
||||||
|
- **Transferencias a cuentas distintas:** un solo `TransferAccount` por pago; con dos cuentas el pago da error.
|
||||||
|
|
||||||
|
## Herramientas útiles
|
||||||
|
|
||||||
|
| Herramienta | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `python -m tools.consultar resumen / descargadas / integradas / errores / orden --name "#8064"` | estado de la BD (solo lectura, hora local; `APP_TIMEZONE`) |
|
||||||
|
| `python -m tools.integrar_ordenes <store> <shopify_id> [--apply]` | dry-run o integración de órdenes puntuales |
|
||||||
|
| `python -m tools.probar_pago <store> <shopify_id>` | payload del pago sobre la Invoice real |
|
||||||
|
| `python -m tools.descargar_ordenes_especiales <store>` | guarda casos especiales en `download/` |
|
||||||
|
| `python -m tools.entrada_mercancia_ordenes <config> --warehouse ... --orders ...` | stock para los SKUs de órdenes puntuales |
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Guía de configuración de tienda
|
||||||
|
|
||||||
|
Cada tienda Shopify que se integra vive en su propio archivo
|
||||||
|
`config/stores/<store_code>.json`. `pipeline/runner.py::cargar_tiendas()`
|
||||||
|
lee **todos** los `.json` de esa carpeta (ignorando los que empiezan con
|
||||||
|
`_`, como `_example.json`) y corre el pipeline completo para cada uno en
|
||||||
|
cada ciclo. Agregar una tienda nueva es tan simple como agregar un archivo
|
||||||
|
más — no requiere tocar código.
|
||||||
|
|
||||||
|
El nombre del archivo no lo usa el código (el loader lee cualquier
|
||||||
|
`*.json`), pero por claridad debe coincidir con el `store_code` de adentro,
|
||||||
|
en minúsculas (ej. `snobbycl.json`, `on-chile-distributor-prod.json`).
|
||||||
|
|
||||||
|
## Plantilla
|
||||||
|
|
||||||
|
Usa `config/stores/_example.json` como punto de partida (no se carga en el
|
||||||
|
pipeline por el `_` inicial):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"store_code": "TIENDA1",
|
||||||
|
"shopify": {
|
||||||
|
"api_id": "TODO",
|
||||||
|
"store": "TODO-nombre-de-la-tienda-myshopify",
|
||||||
|
"api_version": "2026-07",
|
||||||
|
"access_token": "${TIENDA1_SHOPIFY_ACCESS_TOKEN}",
|
||||||
|
"page_limit": 50,
|
||||||
|
"initial_since_id": "TODO-id-de-orden-a-partir-del-cual-empezar-a-integrar-la-primera-vez"
|
||||||
|
},
|
||||||
|
"sap_connection": {
|
||||||
|
"url": "https://TODO-servidor:50000/b1s/v1",
|
||||||
|
"company_db": "TODO-SBO_COMPANYDB",
|
||||||
|
"username": "TODO",
|
||||||
|
"password": "${TIENDA1_SAP_PASSWORD}",
|
||||||
|
"timeout": 15,
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"sap": {
|
||||||
|
"default_warehouse_invoice": "TODO-bodega-virtual-de-esta-tienda",
|
||||||
|
"generic_card_code": "TODO-CardCode-generico-si-el-RUT-no-es-valido",
|
||||||
|
"numbering_series": {
|
||||||
|
"INVOICE": null,
|
||||||
|
"INCOMING_PAYMENT": null,
|
||||||
|
"INVENTORY_TRANSFER_REQUEST": null,
|
||||||
|
"STOCK_TRANSFER": null,
|
||||||
|
"DELIVERY": null
|
||||||
|
},
|
||||||
|
"locations": {
|
||||||
|
"TODO-location_id-de-shopify": { "is_store": true, "warehouse_code": "TODO-bodega-SAP-de-esa-tienda-fisica" },
|
||||||
|
"TODO-location_id-del-CD": { "is_store": false, "warehouse_code": "TODO-bodega-SAP-del-CD" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout_days": 30,
|
||||||
|
"max_orders_per_cycle": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Campo por campo
|
||||||
|
|
||||||
|
### `store_code`
|
||||||
|
|
||||||
|
Identificador corto de la tienda. Se usa en `orders.store_code`, en los
|
||||||
|
logs, en `NumAtCard`/UDFs (`U_ShopifyStore`), y para diferenciar tiendas que
|
||||||
|
comparten la misma base SAP.
|
||||||
|
|
||||||
|
### `shopify`
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `api_id` | Usuario para autenticación HTTP Basic contra la Admin API (junto con `access_token`) |
|
||||||
|
| `store` | Subdominio de `myshopify.com` (sin el `.myshopify.com`) |
|
||||||
|
| `api_version` | Versión de la Admin API, ej. `"2026-07"` |
|
||||||
|
| `access_token` | Token de acceso de la app privada/custom de esa tienda. **No se escribe en el JSON**: va como `${TIENDA_SHOPIFY_ACCESS_TOKEN}` y el valor real en `.env` (ver *Secretos*). **Requiere el scope `read_orders` aprobado** (si no, la descarga de cabeceras falla con 403 `"This action requires merchant approval"`) |
|
||||||
|
| `page_limit` | Tope de cabeceras por página al descargar (`GET orders.json?limit=...`). No limita cuántas se **procesan** por ciclo — eso lo controla `max_orders_per_cycle` |
|
||||||
|
| `initial_since_id` | **Piso** del cursor de descarga: cada ciclo baja desde `max(id máximo guardado en la BD, initial_since_id)`. Evita traer el historial completo y permite saltar órdenes anteriores aunque haya cabeceras más antiguas en la BD (en on-chile, `8103221690695` = último id del 31/08/2026). Las órdenes con id menor nunca se descargan por el ciclo normal; para cargarlas hay que insertar sus cabeceras a mano |
|
||||||
|
|
||||||
|
### `sap_connection`
|
||||||
|
|
||||||
|
Credenciales de SAP Service Layer para esta tienda. Cada tienda puede
|
||||||
|
apuntar a una base SAP distinta (`SAPServiceLayerClient` se instancia una
|
||||||
|
vez por tienda, con su propia sesión).
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `url` | URL base de Service Layer, ej. `https://servidor:50000/b1s/v1` |
|
||||||
|
| `company_db` | Nombre de la base de datos SAP (`CompanyDB` del login) |
|
||||||
|
| `username` / `password` | Usuario de SAP (ej. `manager`). La contraseña va como `${TIENDA_SAP_PASSWORD}` y el valor real en `.env` |
|
||||||
|
| `timeout` | Timeout en segundos para las llamadas HTTP a SAP |
|
||||||
|
| `verify_ssl` | Si se valida el certificado TLS del servidor SAP (`false` es común con certificados autofirmados internos) |
|
||||||
|
|
||||||
|
### `sap`
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `default_warehouse_invoice` | Bodega virtual fija de esta tienda, usada en **todas** las líneas del Invoice (no la bodega real de la tienda/CD) |
|
||||||
|
| `generic_card_code` | `CardCode` a usar cuando no hay RUT válido, o no se pudo crear el `BusinessPartner` |
|
||||||
|
| `numbering_series` | Serie SAP (`Series`) para cada tipo de documento: `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY`. Se consiguen desde SAP Business One → Administración → Definición de numeración de documentos |
|
||||||
|
| `payment_methods` | Mapeo del nombre **exacto** del gateway de Shopify (`transactions[].gateway`) a su forma de pago en SAP. Ver la sección *payment_methods* más abajo |
|
||||||
|
| `locations` | Mapeo `location_id` de Shopify → `{is_store, warehouse_code}`. Define si ese `location_id` es una tienda física (genera `StockTransfer` + `Delivery`) o un CD (solo `InventoryTransferRequest`), y a qué bodega SAP corresponde. El campo opcional `"name"` es solo referencia legible, no lo usa el código |
|
||||||
|
|
||||||
|
**Cómo obtener los `location_id`**: `GET
|
||||||
|
/admin/api/{version}/locations.json` contra esa tienda (hay un ejemplo
|
||||||
|
armado en `tools/shopify.http` → `obtenerLocations`).
|
||||||
|
|
||||||
|
### `timeout_days`
|
||||||
|
|
||||||
|
Días desde `shopify_created_at` antes de marcar una orden como `timeout`
|
||||||
|
(estado terminal, deja de reintentarse) si no llegó a `integrated`.
|
||||||
|
|
||||||
|
### `max_orders_per_cycle`
|
||||||
|
|
||||||
|
Tope de órdenes pendientes que se procesan por ciclo, para esta tienda. Ver
|
||||||
|
`docs/analisis_tiempos.md` para la estimación de capacidad — con el tiempo
|
||||||
|
real medido (~4-5 s/orden), el valor recomendado por defecto es `50`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pasos para agregar una tienda nueva
|
||||||
|
|
||||||
|
1. Crear la app privada/custom en Shopify para esa tienda y conseguir
|
||||||
|
`api_id` + `access_token`. Confirmar que el scope `read_orders` esté
|
||||||
|
**aprobado** por el comerciante (si no, la descarga de cabeceras falla
|
||||||
|
con 403).
|
||||||
|
2. Conseguir los datos de conexión a la base SAP que corresponda
|
||||||
|
(`sap_connection`).
|
||||||
|
3. Consultar `GET locations.json` de esa tienda en Shopify y armar el mapeo
|
||||||
|
`sap.locations` (cuáles son tiendas físicas, cuáles CD, y su bodega SAP).
|
||||||
|
4. Confirmar/crear en SAP: la bodega virtual (`default_warehouse_invoice`),
|
||||||
|
el `CardCode` genérico, y las series de numeración de cada tipo de
|
||||||
|
documento.
|
||||||
|
5. Copiar `_example.json` a `config/stores/<store_code>.json` y completar
|
||||||
|
todos los campos.
|
||||||
|
6. Ubicar el `initial_since_id` (desde qué orden se quiere empezar a
|
||||||
|
integrar) y ponerlo en `shopify.initial_since_id`.
|
||||||
|
7. Confirmar que los UDFs `U_ShopifyOrderId` / `U_ShopifyStore` existan en
|
||||||
|
las tablas de SAP que va a usar el pipeline (`OINV`, ITR, StockTransfer,
|
||||||
|
DeliveryNotes) — si no, hay que crearlos en SAP antes de la primera
|
||||||
|
corrida real.
|
||||||
|
8. Correr un ciclo (`ejecutar_ciclo`) y revisar `notifications/` /
|
||||||
|
`logs/events.jsonl` por si aparece algún error de configuración.
|
||||||
|
|
||||||
|
## Nota sobre secretos
|
||||||
|
|
||||||
|
Cada archivo de tienda tiene credenciales reales (token de Shopify, usuario
|
||||||
|
y clave de SAP). El proyecto todavía no es un repositorio git — cuando se
|
||||||
|
inicialice, hay que agregar `config/stores/*.json` (excepto `_example.json`)
|
||||||
|
al `.gitignore` para no subir secretos por accidente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `payment_methods` (dentro de `sap`)
|
||||||
|
|
||||||
|
La clave es el nombre **exacto** del gateway tal como llega en `transactions[].gateway` de
|
||||||
|
Shopify (no el de `payment_gateway_names`), sensible a mayúsculas. Si un pago usa un gateway
|
||||||
|
que no está aquí, el pago **no se crea** (documento en `error`, con el mensaje).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"payment_methods": {
|
||||||
|
"Transbank Webpay": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 },
|
||||||
|
"Transbank": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 },
|
||||||
|
"reversso": { "type": "transfer", "transfer_account": "1-1-010-20-000" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `credit_card` (línea en `PaymentCreditCards`) o `transfer` (`TransferAccount`/`TransferSum`). Otro valor da error |
|
||||||
|
| `credit_card` | Código de la tarjeta en SAP (`CreditCards`); cada medio tiene la suya, con su cuenta contable. Obligatorio si `type = credit_card` |
|
||||||
|
| `split_payment` | `SplitPayments` de la línea de tarjeta (`tYES`/`tNO`) |
|
||||||
|
| `num_of_payments` | `NumOfPayments` (cuotas) |
|
||||||
|
| `transfer_account` | Cuenta contable de la transferencia. Obligatorio si `type = transfer`. Solo una cuenta distinta por pago |
|
||||||
|
| `payment_method_code` | Opcional: `PaymentMethodCode` de SAP para la línea de tarjeta (hoy no se usa) |
|
||||||
|
|
||||||
|
Un mismo medio puede aparecer con variantes de nombre (ej. `Venti` y `VentiPay`). Datos fijos de la línea
|
||||||
|
de tarjeta (en `domain/payment.py`): últimos dígitos `1234` y vencimiento `2030-12-31`, `VoucherNum` = `name` de la orden.
|
||||||
|
Para descubrir los nombres reales: `python -m tools.descargar_ordenes_especiales <store>` o revisar
|
||||||
|
`transactions.json` de una orden. Casos y ejemplos: `docs/casos_especiales.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secretos (`.env`)
|
||||||
|
|
||||||
|
Los tokens y contraseñas **no se guardan en el JSON**. Se escriben como `${NOMBRE}` y el valor real va en el
|
||||||
|
archivo `.env` de la raíz (o en variables de entorno del equipo/servicio):
|
||||||
|
|
||||||
|
```
|
||||||
|
ON_CHILE_SHOPIFY_ACCESS_TOKEN=shpat_...
|
||||||
|
ON_CHILE_SAP_PASSWORD=...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `core/config.py::cargar_config_tienda()` reemplaza cada `${NOMBRE}` al leer el JSON (desde el entorno o `.env`;
|
||||||
|
el entorno tiene prioridad). Si falta alguna variable, falla con un mensaje claro (`ConfigError`).
|
||||||
|
- Los valores escritos directamente en el JSON siguen funcionando.
|
||||||
|
- `.env` está en `.gitignore`; `.env.example` lista las variables necesarias. Una pareja
|
||||||
|
`<TIENDA>_SHOPIFY_ACCESS_TOKEN` / `<TIENDA>_SAP_PASSWORD` por tienda.
|
||||||
|
- Todas las herramientas de `tools/` leen la configuración con este mismo cargador.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Esquema de base de datos (SQLite)
|
||||||
|
|
||||||
|
Archivo: `data/shopify_sap.db`. Definido y accedido desde `core/db.py`. Tres
|
||||||
|
tablas: `orders` (cabecera), `order_fulfillments` (un fulfillment de
|
||||||
|
Shopify) y `order_documents` (un documento SAP requerido/creado).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
orders ||--o{ order_fulfillments : "tiene"
|
||||||
|
orders ||--o{ order_documents : "tiene"
|
||||||
|
order_fulfillments ||--o{ order_documents : "tiene (si aplica)"
|
||||||
|
|
||||||
|
orders {
|
||||||
|
int sys_id PK
|
||||||
|
string store_code
|
||||||
|
int shopify_order_id
|
||||||
|
string order_number
|
||||||
|
string tags
|
||||||
|
string payment_gateway_names
|
||||||
|
string shopify_created_at
|
||||||
|
string time_insert
|
||||||
|
string time_update
|
||||||
|
string financial_status
|
||||||
|
string fulfillment_status
|
||||||
|
string card_code
|
||||||
|
string status
|
||||||
|
string last_error
|
||||||
|
}
|
||||||
|
order_fulfillments {
|
||||||
|
int sys_id PK
|
||||||
|
int order_sys_id FK
|
||||||
|
int shopify_fulfillment_id
|
||||||
|
string location_id
|
||||||
|
bool is_store
|
||||||
|
string warehouse_code
|
||||||
|
string time_insert
|
||||||
|
}
|
||||||
|
order_documents {
|
||||||
|
int sys_id PK
|
||||||
|
int order_sys_id FK
|
||||||
|
int fulfillment_sys_id FK
|
||||||
|
string doc_type
|
||||||
|
int sequence
|
||||||
|
bool required
|
||||||
|
string status
|
||||||
|
int sap_doc_entry
|
||||||
|
int sap_doc_num
|
||||||
|
int base_doc_entry
|
||||||
|
string error_message
|
||||||
|
int attempts
|
||||||
|
string time_update
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `orders`
|
||||||
|
|
||||||
|
Una fila por orden de Shopify (`UNIQUE(store_code, shopify_order_id)`, así
|
||||||
|
el mismo id de Shopify puede repetirse entre tiendas distintas).
|
||||||
|
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `sys_id` | INTEGER PK | id interno, autoincremental |
|
||||||
|
| `store_code` | TEXT | qué tienda (`config/stores/<store_code>.json`) |
|
||||||
|
| `shopify_order_id` | INTEGER | `id` de Shopify |
|
||||||
|
| `order_number` | TEXT | campo `name` de Shopify (ej. `"#7863"`) |
|
||||||
|
| `tags` | TEXT | tal cual viene de Shopify, separado por coma |
|
||||||
|
| `payment_gateway_names` | TEXT | medio(s) de pago, unidos con `" + "` si hay más de uno |
|
||||||
|
| `shopify_created_at` | TEXT | fecha de creación real en Shopify — dispara el `timeout` |
|
||||||
|
| `time_insert` | TEXT | cuándo se descargó/insertó por primera vez (auditoría) |
|
||||||
|
| `time_update` | TEXT | última vez que se tocó esta orden — define el orden de la cola (ver `status`) |
|
||||||
|
| `financial_status` | TEXT | último valor conocido de Shopify (puede cambiar entre corridas) |
|
||||||
|
| `fulfillment_status` | TEXT | ídem — `null`/`partial`/`fulfilled` |
|
||||||
|
| `card_code` | TEXT | `CardCode` de SAP ya resuelto (se cachea, no se vuelve a resolver) |
|
||||||
|
| `status` | TEXT | ver tabla de estados abajo |
|
||||||
|
| `last_error` | TEXT | último mensaje de error (ya diagnosticado, no el crudo de SAP si se reconoció) |
|
||||||
|
|
||||||
|
### Valores de `status`
|
||||||
|
|
||||||
|
| Valor | Terminal | Significado |
|
||||||
|
|---|---|---|
|
||||||
|
| `pending` | No | Cabecera descargada, aún no analizada |
|
||||||
|
| `not_eligible` | No | No está `paid`, o no tiene `fulfillments` todavía |
|
||||||
|
| `in_progress` | No | Tiene al menos un fulfillment procesado, pero `fulfillment_status != "fulfilled"` (pueden llegar más) |
|
||||||
|
| `error` | No | El último intento falló en algún documento; se reintenta en la próxima rotación |
|
||||||
|
| `integrated` | **Sí** | Todos los documentos requeridos están `created` y `fulfillment_status == "fulfilled"` |
|
||||||
|
| `timeout` | **Sí** | Pasaron más de `timeout_days` desde `shopify_created_at` sin llegar a `integrated` |
|
||||||
|
| `cancelled` | **Sí** | La orden tiene `cancelled_at` en Shopify (anulada). El pipeline la marca solo, con la fecha y el `cancel_reason` en `last_error`; nunca crea documentos en SAP |
|
||||||
|
| `ignored` | **Sí** | Marcada a mano para que el pipeline no la procese nunca (ej. pedidos de prueba). No se asigna automáticamente |
|
||||||
|
|
||||||
|
Solo `integrated`, `timeout`, `ignored` y `cancelled` son terminales —
|
||||||
|
`get_pending_orders` y `mark_orders_timeout` excluyen esos cuatro, todo lo demás
|
||||||
|
se sigue reintentando.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `order_fulfillments`
|
||||||
|
|
||||||
|
Una fila por `fulfillment` de Shopify (`UNIQUE(order_sys_id,
|
||||||
|
shopify_fulfillment_id)`). Una orden puede tener varios (envío parcial).
|
||||||
|
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `sys_id` | INTEGER PK | |
|
||||||
|
| `order_sys_id` | FK → `orders.sys_id` | |
|
||||||
|
| `shopify_fulfillment_id` | INTEGER | `id` del fulfillment en Shopify |
|
||||||
|
| `location_id` | TEXT | `location_id` de Shopify de ese fulfillment |
|
||||||
|
| `is_store` | INTEGER (bool) | snapshot de `config.sap.locations[location_id].is_store` al momento de procesarlo |
|
||||||
|
| `warehouse_code` | TEXT | bodega SAP resuelta para ese `location_id` |
|
||||||
|
| `time_insert` | TEXT | cuándo se detectó este fulfillment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `order_documents`
|
||||||
|
|
||||||
|
Una fila por documento SAP requerido (exista o no todavía). Documentos a
|
||||||
|
nivel orden (`INVOICE`, `INCOMING_PAYMENT`) tienen `fulfillment_sys_id =
|
||||||
|
NULL`; los de nivel fulfillment (`INVENTORY_TRANSFER_REQUEST`,
|
||||||
|
`STOCK_TRANSFER`, `DELIVERY`) lo tienen seteado.
|
||||||
|
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `sys_id` | INTEGER PK | |
|
||||||
|
| `order_sys_id` | FK → `orders.sys_id` | |
|
||||||
|
| `fulfillment_sys_id` | FK → `order_fulfillments.sys_id`, nullable | `NULL` para documentos de orden |
|
||||||
|
| `doc_type` | TEXT | `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY` |
|
||||||
|
| `sequence` | INTEGER | orden de creación (10/20/30/40/50 — incrementos de 10 para insertar tipos nuevos sin reordenar) |
|
||||||
|
| `required` | INTEGER (bool) | si este documento aplica para esta orden/fulfillment |
|
||||||
|
| `status` | TEXT | `pending` / `created` / `error` |
|
||||||
|
| `sap_doc_entry` / `sap_doc_num` | INTEGER | identificadores reales en SAP, una vez creado |
|
||||||
|
| `base_doc_entry` | INTEGER | *(reservado, no usado todavía — el `BaseEntry` real se arma al vuelo en `domain/*` a partir del documento base)* |
|
||||||
|
| `error_message` | TEXT | mensaje ya diagnosticado (ver `domain/documents.py::diagnosticar_error`) |
|
||||||
|
| `attempts` | INTEGER | cuántas veces falló |
|
||||||
|
| `time_update` | TEXT | última vez que se tocó esta fila |
|
||||||
|
|
||||||
|
### Valores de `status`
|
||||||
|
|
||||||
|
| Valor | Significado |
|
||||||
|
|---|---|
|
||||||
|
| `pending` | Fila creada (el documento es requerido), todavía no se intentó |
|
||||||
|
| `created` | Ya existe en SAP (`sap_doc_entry`/`sap_doc_num` poblados) — en un reintento, se relee con `GET` en vez de volver a crear |
|
||||||
|
| `error` | Falló el último intento — se reintenta en la próxima corrida |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funciones de `core/db.py` por tabla
|
||||||
|
|
||||||
|
**`orders`**: `insert_order_header`, `get_last_shopify_order_id`,
|
||||||
|
`get_order_sys_id`, `mark_orders_timeout`, `get_pending_orders`,
|
||||||
|
`set_order_status`, `set_order_card_code`, `update_order_snapshot`.
|
||||||
|
|
||||||
|
**`order_fulfillments`**: `ensure_order_fulfillment` (insert-si-no-existe,
|
||||||
|
por `shopify_fulfillment_id`).
|
||||||
|
|
||||||
|
**`order_documents`**: `ensure_order_document` (insert-si-no-existe, por
|
||||||
|
`doc_type` + `fulfillment_sys_id`), `get_order_document`,
|
||||||
|
`mark_document_created`, `mark_document_error`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota: precisión de `time_update`
|
||||||
|
|
||||||
|
Todos los `UPDATE`/`DEFAULT` de timestamp usan
|
||||||
|
`strftime('%Y-%m-%d %H:%M:%f','now')` (milisegundos), no `datetime('now')`
|
||||||
|
(que solo tiene precisión de segundo). Esto es necesario para que la
|
||||||
|
rotación justa de `get_pending_orders` (`ORDER BY time_update ASC`)
|
||||||
|
funcione bien cuando el pipeline toca varias órdenes rápido dentro del
|
||||||
|
mismo segundo.
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Flujo del pipeline Shopify → SAP Business One
|
||||||
|
|
||||||
|
Este documento describe, caso por caso, cómo `analizar_y_procesar_orden`
|
||||||
|
(`pipeline/runner.py`) procesa una orden. El pipeline evalúa condiciones en
|
||||||
|
orden y cada orden termina en uno de estos casos según cómo venga desde
|
||||||
|
Shopify.
|
||||||
|
|
||||||
|
## Punto de entrada
|
||||||
|
|
||||||
|
Cada ciclo (`ejecutar_ciclo`, disparado por el scheduler en `main.py`):
|
||||||
|
|
||||||
|
1. `descargar_cabeceras_nuevas`: baja cabeceras nuevas desde Shopify
|
||||||
|
(`since_id` = último id guardado para esa tienda) y las guarda en `orders`
|
||||||
|
con `status = 'pending'`.
|
||||||
|
2. `obtener_ordenes_pendientes`: marca como `timeout` las vencidas (ver
|
||||||
|
Caso F) y devuelve las que no llegaron a un estado terminal, ordenadas
|
||||||
|
por `time_update ASC` (rotación justa — ver nota al final).
|
||||||
|
3. Por cada orden pendiente (hasta `max_orders_per_cycle`), se llama a
|
||||||
|
`analizar_y_procesar_orden`.
|
||||||
|
|
||||||
|
### Diagrama general (color por caso)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Cycle["Inicio del ciclo:\nmark_orders_timeout\n(sobre TODAS las pendientes)"] -->|vencidas| CaseG["Caso G\ntimeout"]
|
||||||
|
Cycle -->|el resto| Start(["Por cada orden pendiente\n(analizar_y_procesar_orden)"])
|
||||||
|
|
||||||
|
Start --> ChkPaid{"¿paid AND\ntiene fulfillments?"}
|
||||||
|
ChkPaid -->|No| CaseA["Caso A\nnot_eligible"]
|
||||||
|
ChkPaid -->|Sí| ChkShip{"¿costo de\nenvío > 0?"}
|
||||||
|
ChkShip -->|Sí| CaseB["Caso B\nerror: envío\nno soportado"]
|
||||||
|
ChkShip -->|No| CaseC["Caso C\nResolver RUT\ny CardCode"]
|
||||||
|
CaseC --> DocInv["POST Invoices\n+ POST IncomingPayments"]
|
||||||
|
DocInv -->|error| CaseH["Caso H\nerror, se\nreintenta luego"]
|
||||||
|
DocInv -->|ok| LoopF{"¿Quedan\nfulfillments por\nprocesar?"}
|
||||||
|
LoopF -->|No| ChkMore{"¿fulfillment_status\n== fulfilled?"}
|
||||||
|
LoopF -->|Sí| ChkStore{"¿location_id\nes tienda?"}
|
||||||
|
ChkStore -->|Sí| CaseD["Caso D\nITR + StockTransfer\n+ Delivery"]
|
||||||
|
ChkStore -->|No| CaseE["Caso E\nSolo ITR"]
|
||||||
|
CaseD -->|ok, siguiente| LoopF
|
||||||
|
CaseE -->|ok, siguiente| LoopF
|
||||||
|
CaseD -->|error| CaseH
|
||||||
|
CaseE -->|error| CaseH
|
||||||
|
ChkMore -->|No, esperando más| CaseF["Caso F\nin_progress"]
|
||||||
|
ChkMore -->|Sí| Done["integrated"]
|
||||||
|
|
||||||
|
classDef caseA fill:#fde68a,stroke:#92400e,color:#000
|
||||||
|
classDef caseB fill:#fca5a5,stroke:#991b1b,color:#000
|
||||||
|
classDef caseC fill:#bfdbfe,stroke:#1e3a8a,color:#000
|
||||||
|
classDef caseD fill:#86efac,stroke:#14532d,color:#000
|
||||||
|
classDef caseE fill:#5eead4,stroke:#134e4a,color:#000
|
||||||
|
classDef caseF fill:#d8b4fe,stroke:#581c87,color:#000
|
||||||
|
classDef caseG fill:#d1d5db,stroke:#1f2937,color:#000
|
||||||
|
classDef caseH fill:#fdba74,stroke:#7c2d12,color:#000
|
||||||
|
classDef terminal fill:#4ade80,stroke:#14532d,color:#000
|
||||||
|
|
||||||
|
class CaseA caseA
|
||||||
|
class CaseB caseB
|
||||||
|
class CaseC caseC
|
||||||
|
class CaseD caseD
|
||||||
|
class CaseE caseE
|
||||||
|
class CaseF caseF
|
||||||
|
class CaseG caseG
|
||||||
|
class CaseH caseH
|
||||||
|
class Done terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
*Nota: `LoopF` representa el `for fulfillment in detalle["fulfillments"]` del código — se
|
||||||
|
evalúa una vez por cada fulfillment de la orden, y `ChkMore` (¿ya no quedan?) se
|
||||||
|
resuelve recién cuando termina de recorrerlos todos.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso A — Orden no elegible
|
||||||
|
|
||||||
|
**Filtro:** `financial_status != "paid"` **o** `fulfillments` vacío.
|
||||||
|
|
||||||
|
**Resultado:** `orders.status = "not_eligible"`. No se crea ningún documento
|
||||||
|
en SAP. La orden se vuelve a revisar en el próximo ciclo (por si cambia de
|
||||||
|
estado en Shopify), hasta que cumpla la condición o llegue al timeout
|
||||||
|
(Caso F).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso B — Costo de envío no soportado
|
||||||
|
|
||||||
|
**Filtro:** `total_shipping_price_set.shop_money.amount > 0`.
|
||||||
|
|
||||||
|
**Resultado:** `orders.status = "error"`, con `last_error` explicando que el
|
||||||
|
mapeo de envío aún no está soportado. No se crea ningún documento. Queda
|
||||||
|
pendiente definir cómo reflejar el flete en el Invoice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso C — Resolución de cliente (sub-flujo, aplica a todos los casos D/E)
|
||||||
|
|
||||||
|
Antes de crear la Invoice, se resuelve el `CardCode`:
|
||||||
|
|
||||||
|
1. **Buscar RUT** (`buscar_rut_en_order`): solo en `note_attributes` de la
|
||||||
|
orden (clave que contenga "rut", validado con módulo 11). No se consultan
|
||||||
|
los metafields del customer.
|
||||||
|
2. **Sin RUT válido** → se usa el `CardCode` genérico de la tienda
|
||||||
|
(`sap.generic_card_code`).
|
||||||
|
3. **Con RUT válido** → se busca `BusinessPartner` en SAP por `FederalTaxID`
|
||||||
|
(formato `NNNNNNNN-D`):
|
||||||
|
- Si existe → se usa ese `CardCode`.
|
||||||
|
- Si no existe → se **crea** un `BusinessPartner` nuevo (`CardCode = "C" +
|
||||||
|
RUT`, `CardType: cCustomer`, `GroupCode: 100`, con `BPAddresses`
|
||||||
|
Bill-To/Ship-To). Si la creación falla, se cae al genérico.
|
||||||
|
4. En cualquier caso donde haya RUT válido, se guarda también en formato SAP
|
||||||
|
(`rut_sap`) para sobrescribir `FederalTaxID` en la Invoice — así el RUT
|
||||||
|
real del cliente queda en el documento aunque se use el `CardCode`
|
||||||
|
genérico.
|
||||||
|
|
||||||
|
El `CardCode` resuelto se guarda en `orders.card_code` y no se vuelve a
|
||||||
|
resolver en corridas posteriores de la misma orden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso D — Fulfillment por tienda física (`is_store: true`)
|
||||||
|
|
||||||
|
**Filtro:** el `location_id` del fulfillment está mapeado en
|
||||||
|
`config/stores/<tienda>.json → sap.locations` con `is_store: true`.
|
||||||
|
|
||||||
|
**Secuencia de documentos** (una Invoice/Payment por orden, el resto por
|
||||||
|
cada fulfillment):
|
||||||
|
|
||||||
|
| Orden | Documento | Base de datos previa | Notas |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `Invoices` (`ReserveInvoice: tYES`) | — | `CardCode`/`CardName`/`FederalTaxID`/direcciones sobrescritas con datos reales del cliente. Bodega = `default_warehouse_invoice`. |
|
||||||
|
| 2 | `IncomingPayments` | Invoice | Aplicado a la Invoice (`PaymentInvoices`). `TransferAccount` fijo, `Remarks` = medio(s) de pago de Shopify. |
|
||||||
|
| 3 | `InventoryTransferRequests` | — | Una por fulfillment. Bodega origen = la del `location_id` (bodega real); destino = `default_warehouse_invoice` (virtual). |
|
||||||
|
| 4 | `StockTransfers` | ITR (`BaseType/BaseEntry/BaseLine`) | Mismo sentido que la ITR que satisface. |
|
||||||
|
| 5 | `DeliveryNotes` | Invoice (`BaseType/BaseEntry/BaseLine`) | Solo entrega los ítems de ese fulfillment (line items del StockTransfer, no toda la orden). |
|
||||||
|
|
||||||
|
Cada documento creado a partir de la Invoice (ITR, StockTransfer, Delivery)
|
||||||
|
se enlaza de vuelta a ella vía `DocumentReferences` (`PATCH Invoices`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#86efac', 'primaryBorderColor': '#14532d', 'primaryTextColor': '#000', 'actorBkg': '#86efac', 'actorBorder': '#14532d', 'signalColor': '#14532d', 'signalTextColor': '#000'}}}%%
|
||||||
|
sequenceDiagram
|
||||||
|
participant P as Pipeline
|
||||||
|
participant S as Shopify
|
||||||
|
participant SAP as SAP Service Layer
|
||||||
|
|
||||||
|
P->>S: GET orders/{id}.json
|
||||||
|
opt hay RUT válido (note_attributes)
|
||||||
|
P->>SAP: GET BusinessPartners (por RUT)
|
||||||
|
opt no existe el BP
|
||||||
|
P->>SAP: POST BusinessPartners
|
||||||
|
end
|
||||||
|
end
|
||||||
|
P->>SAP: POST Invoices
|
||||||
|
P->>SAP: POST IncomingPayments
|
||||||
|
loop por cada fulfillment de tienda
|
||||||
|
P->>SAP: POST InventoryTransferRequests
|
||||||
|
P->>SAP: GET Invoices / PATCH Invoices (ref. ITR)
|
||||||
|
P->>SAP: GET InventoryTransferRequests (líneas)
|
||||||
|
P->>SAP: POST StockTransfers
|
||||||
|
P->>SAP: GET Invoices / PATCH Invoices (ref. StockTransfer)
|
||||||
|
P->>SAP: GET StockTransfers (líneas)
|
||||||
|
P->>SAP: POST DeliveryNotes
|
||||||
|
P->>SAP: GET Invoices / PATCH Invoices (ref. Delivery)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado final:** si todos los documentos de todos los fulfillments se
|
||||||
|
crean bien y `fulfillment_status == "fulfilled"` → `status = "integrated"`.
|
||||||
|
Si falta algún fulfillment por llegar → `status = "in_progress"` (ver Caso
|
||||||
|
E). Si algo falla → `status = "error"` (ver Caso G).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso E — Fulfillment por centro de distribución (`is_store: false`)
|
||||||
|
|
||||||
|
Mismo flujo que el Caso D, pero se **detiene después de la ITR**: no se
|
||||||
|
crea `StockTransfer` ni `DeliveryNotes` para ese fulfillment.
|
||||||
|
|
||||||
|
| Orden | Documento |
|
||||||
|
|---|---|
|
||||||
|
| 1 | `Invoices` |
|
||||||
|
| 2 | `IncomingPayments` |
|
||||||
|
| 3 | `InventoryTransferRequests` |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#5eead4', 'primaryBorderColor': '#134e4a', 'primaryTextColor': '#000', 'actorBkg': '#5eead4', 'actorBorder': '#134e4a', 'signalColor': '#134e4a', 'signalTextColor': '#000'}}}%%
|
||||||
|
sequenceDiagram
|
||||||
|
participant P as Pipeline
|
||||||
|
participant SAP as SAP Service Layer
|
||||||
|
|
||||||
|
P->>SAP: POST Invoices
|
||||||
|
P->>SAP: POST IncomingPayments
|
||||||
|
loop por cada fulfillment de CD
|
||||||
|
P->>SAP: POST InventoryTransferRequests
|
||||||
|
P->>SAP: GET Invoices / PATCH Invoices (ref. ITR)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso F — Multi-fulfillment / envío parcial
|
||||||
|
|
||||||
|
Una orden puede tener varios `fulfillments` a lo largo del tiempo (envío
|
||||||
|
parcial). Cada corrida:
|
||||||
|
|
||||||
|
1. Vuelve a descargar el detalle completo de la orden.
|
||||||
|
2. Recorre **todos** los fulfillments que trae Shopify en ese momento.
|
||||||
|
3. Para cada uno, `ensure_order_fulfillment`/`ensure_order_document` crean
|
||||||
|
la fila solo si no existe — los fulfillments/documentos ya procesados en
|
||||||
|
corridas anteriores se detectan (`status == "created"`) y se saltan (se
|
||||||
|
hace `GET` en vez de `POST`), nunca se duplican.
|
||||||
|
4. Si aparece un fulfillment nuevo que antes no estaba, se procesa como
|
||||||
|
Caso D o E según su `location_id`, sin tocar lo ya integrado.
|
||||||
|
|
||||||
|
**Resultado:** la orden queda en `in_progress` mientras
|
||||||
|
`fulfillment_status != "fulfilled"`, y pasa a `integrated` recién cuando
|
||||||
|
Shopify indica que ya no se esperan más fulfillments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso G — Timeout
|
||||||
|
|
||||||
|
**Filtro** (`mark_orders_timeout`, se corre al inicio de cada ciclo): la
|
||||||
|
orden no llegó a `integrated`, y pasaron más de `timeout_days` (config por
|
||||||
|
tienda) desde `shopify_created_at`.
|
||||||
|
|
||||||
|
**Resultado:** `orders.status = "timeout"` (estado terminal, no se vuelve a
|
||||||
|
procesar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caso H — Error y reintento
|
||||||
|
|
||||||
|
Cualquier documento puede fallar (SAP rechaza el POST). Al fallar:
|
||||||
|
|
||||||
|
1. `mark_document_error` marca esa fila de `order_documents` como `error`
|
||||||
|
(guarda el mensaje) y `orders.status = "error"` con ese mismo mensaje.
|
||||||
|
2. Antes de relanzar el error, `domain/documents.py::diagnosticar_error`
|
||||||
|
intenta reconocer la causa real y devolver un mensaje claro:
|
||||||
|
- **Tipo de cambio faltante**: "No hay tipo de cambio cargado en SAP
|
||||||
|
para la moneda X".
|
||||||
|
- **ItemCode inexistente**: revisa cada SKU contra SAP y dice cuál(es)
|
||||||
|
no existen, en vez del mensaje genérico de SAP.
|
||||||
|
- Cualquier otro error: se deja el mensaje original de SAP tal cual.
|
||||||
|
3. La orden sigue apareciendo en `obtener_ordenes_pendientes` (no es un
|
||||||
|
estado terminal) y se reintenta en una corrida futura.
|
||||||
|
4. Al reintentar, cada documento que ya estaba `created` se **reutiliza**
|
||||||
|
(se lee de SAP, no se vuelve a crear) — solo se reintenta lo que falló.
|
||||||
|
5. Los errores quedan disponibles para revisión en
|
||||||
|
`core/notifications.py::generar_reporte_errores_html()` (HTML guardado
|
||||||
|
en `notifications/`, el envío por mail queda pendiente para más
|
||||||
|
adelante).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota: rotación justa entre reintentos
|
||||||
|
|
||||||
|
`obtener_ordenes_pendientes` ordena por `time_update ASC`: la orden que
|
||||||
|
hace más tiempo no se toca va primero. Cada vez que se procesa una orden
|
||||||
|
(éxito o error) se actualiza su `time_update`, así que pasa al final de la
|
||||||
|
fila. Esto evita que una orden que falla repetido monopolice el cupo de
|
||||||
|
`max_orders_per_cycle` y bloquee a las órdenes nuevas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas vigentes que amplían este flujo
|
||||||
|
|
||||||
|
Este documento describe el flujo base. Las reglas siguientes lo complementan (detalle y órdenes de ejemplo en
|
||||||
|
`docs/casos_especiales.md`):
|
||||||
|
|
||||||
|
- **Elegibilidad:** `cancelled_at` no vacío → `cancelled` (terminal, nunca se integra). Se integran las órdenes
|
||||||
|
`paid`, `partially_refunded` y `refunded` con fulfillments; el reembolso es un tratamiento interno de comercial y no
|
||||||
|
se refleja en SAP. `pending`/`voided` y las órdenes sin despachar siguen como `not_eligible`.
|
||||||
|
- **Montos:** la Invoice iguala lo realmente cobrado (transacciones `success` de tipo `sale`/`capture`); se elige
|
||||||
|
entre líneas originales y vigentes, y los fulfillments se limitan a lo facturado.
|
||||||
|
- **Pago:** por transacción efectiva, con la tarjeta o cuenta de `sap.payment_methods`.
|
||||||
|
- **Idempotencia contra SAP:** antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento ya
|
||||||
|
existente para la orden (`U_ShopifyOrderId` + `U_ShopifyStore`, y mismas líneas); si existe se adopta y no se
|
||||||
|
duplica. Una Invoice ya pagada en SAP no genera otro pago.
|
||||||
|
- **Red:** `ShopifyClient` reintenta hasta 4 veces ante fallos de red (SSL, conexión, timeout), con espera creciente.
|
||||||
|
- **Cursor:** `max(id máximo en BD, initial_since_id)`.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Mapeo de campos Shopify → SAP Business One
|
||||||
|
|
||||||
|
Referencia de cómo se arma cada documento SAP a partir del JSON de una orden
|
||||||
|
de Shopify. Los constructores viven en `domain/*.py`.
|
||||||
|
|
||||||
|
## RUT del cliente (`domain/business_partner.py`)
|
||||||
|
|
||||||
|
| Prioridad | Fuente | Detalle |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `order.note_attributes` | Se busca una entrada cuyo `name` contenga "rut" (sin distinguir mayúsculas). Se valida el `value` con el formato y el dígito verificador (módulo 11). |
|
||||||
|
| 2 (respaldo) | Metafields del `customer` (Shopify) | Solo si no apareció en `note_attributes`. Se recorre cada metafield buscando un `value` con formato de RUT válido. |
|
||||||
|
|
||||||
|
- `normalizar_rut`: quita puntos/guión, mayúsculas (ej. `"123456785"`) — uso
|
||||||
|
interno para validar.
|
||||||
|
- `formatear_rut`: formato que usa SAP, `"NNNNNNNN-D"` (ej. `"12345678-5"`)
|
||||||
|
— se usa en `FederalTaxID`, `CardCode` y el filtro de búsqueda en SAP.
|
||||||
|
|
||||||
|
## Resolución de CardCode (`resolver_card_code`)
|
||||||
|
|
||||||
|
| Caso | CardCode usado |
|
||||||
|
|---|---|
|
||||||
|
| Sin RUT válido | `sap.generic_card_code` (config de la tienda) |
|
||||||
|
| RUT válido, existe `BusinessPartner` con ese `FederalTaxID` | El `CardCode` de ese BP |
|
||||||
|
| RUT válido, no existe | Se **crea** un BP nuevo, `CardCode = "C" + rut_sap` |
|
||||||
|
| RUT válido, pero falla la creación del BP | Cae a `sap.generic_card_code` |
|
||||||
|
|
||||||
|
En los tres primeros casos con RUT válido, `rut_sap` se guarda igual (para
|
||||||
|
`FederalTaxID` del Invoice), aunque termine usándose el `CardCode` genérico.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `BusinessPartners` (creación, `domain/business_partner.py`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen Shopify | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` | — | `"C" + rut_sap` |
|
||||||
|
| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` |
|
||||||
|
| `CardType` | — | fijo `"cCustomer"` |
|
||||||
|
| `GroupCode` | — | fijo `100` ("Clientes", mismo grupo que el genérico) |
|
||||||
|
| `FederalTaxID` | — | `rut_sap` |
|
||||||
|
| `Phone1` | `billing_address.phone`, si no `customer.phone` | |
|
||||||
|
| `EmailAddress` | `order.email`, si no `order.contact_email` | |
|
||||||
|
|
||||||
|
### `BPAddresses` (dos entradas)
|
||||||
|
|
||||||
|
| Campo SAP | Origen Shopify | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `AddressName` | — | `"BillTo"` / `"ShipTo"` |
|
||||||
|
| `AddressType` | — | `bo_BillTo` / `bo_ShipTo` |
|
||||||
|
| `Street` | `address1` | |
|
||||||
|
| `Block` | `address2` | |
|
||||||
|
| `City` | `city` | |
|
||||||
|
| `ZipCode` | `zip` | |
|
||||||
|
| `County` | `province` | texto libre, no se traduce a código de región |
|
||||||
|
| `Country` | `country_code` | solo si es `"CL"` |
|
||||||
|
| `TaxCode` | — | fijo `"IVA"`, solo en la dirección `bo_ShipTo` |
|
||||||
|
|
||||||
|
`bo_BillTo` sale de `order.billing_address`; `bo_ShipTo` de
|
||||||
|
`order.shipping_address` (si no hay, usa `billing_address`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Invoices` (factura de reserva, `domain/invoice.py`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen Shopify | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` | — | resuelto (ver arriba) |
|
||||||
|
| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` |
|
||||||
|
| `DocDate` / `DocDueDate` | `created_at` (solo fecha) | mismo valor en ambos |
|
||||||
|
| `Series` | — | `sap.numbering_series.INVOICE` (config tienda) |
|
||||||
|
| `DocType` | — | fijo `"dDocument_Items"` |
|
||||||
|
| `DocCurrency` | — | fijo `"CLP"` |
|
||||||
|
| `ReserveInvoice` | — | fijo `"tYES"` (factura de reserva, no releva stock al crearse) |
|
||||||
|
| `NumAtCard` | `order.name` | ej. `"#7863"`, para ubicarla fácil |
|
||||||
|
| `FederalTaxID` | `rut_sap` | solo si hay RUT válido; sobrescribe el del CardCode genérico |
|
||||||
|
| `U_ShopifyOrderId` | `order.id` | UDF |
|
||||||
|
| `U_ShopifyStore` | — | `store_code` de la config |
|
||||||
|
|
||||||
|
### `AddressExtension` (prefijo `BillTo`/`ShipTo`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen Shopify | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `{p}Street` | `address1` | |
|
||||||
|
| `{p}Block` | `address2` | |
|
||||||
|
| `{p}Building` | `phone` | reutilizado para guardar el teléfono |
|
||||||
|
| `{p}City` | `city` | |
|
||||||
|
| `{p}ZipCode` | `zip` | |
|
||||||
|
| `{p}County` | `province` | |
|
||||||
|
| `{p}Country` | `country_code` | solo si es `"CL"` |
|
||||||
|
|
||||||
|
`BillTo*` sale de `billing_address`, `ShipTo*` de `shipping_address`. SAP
|
||||||
|
calcula `Address`/`Address2` (texto plano) solo a partir de estos campos —
|
||||||
|
no se arman a mano.
|
||||||
|
|
||||||
|
### `DocumentLines` (una por `line_item` con `sku`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen Shopify | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `ItemCode` | `sku` | |
|
||||||
|
| `Quantity` | `quantity` | |
|
||||||
|
| `WarehouseCode` | — | `sap.default_warehouse_invoice` (bodega virtual, fija por tienda) |
|
||||||
|
| `PriceAfterVAT` | `price` menos descuento, ver nota | precio unitario, IVA incluido |
|
||||||
|
| `TaxCode` | — | fijo `"IVA"` (hardcode, sin lista de precios) |
|
||||||
|
|
||||||
|
**Cálculo de `PriceAfterVAT`**: `item.price` es el precio unitario **antes**
|
||||||
|
de descuento. El descuento real está en `discount_allocations` (el campo
|
||||||
|
`total_discount` no siempre lo refleja). Fórmula:
|
||||||
|
|
||||||
|
```
|
||||||
|
PriceAfterVAT = (price × quantity − Σ discount_allocations.amount) / quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `IncomingPayments` (`domain/payment.py`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` | `invoice.CardCode` | mismo cliente que la Invoice |
|
||||||
|
| `CardName` | `invoice.CardName` | |
|
||||||
|
| `DocType` | — | fijo `"rCustomer"` |
|
||||||
|
| `DocDate` | `invoice.DocDate` | misma fecha que la Invoice |
|
||||||
|
| `Series` | — | `sap.numbering_series.INCOMING_PAYMENT` |
|
||||||
|
| `DocCurrency` | — | fijo `"CLP"` |
|
||||||
|
| `PaymentCreditCards[]` | una línea por transacción efectiva con `type = credit_card` | `CreditCard` = `sap.payment_methods[gateway].credit_card`; `CreditCardNumber` fijo `"1234"`; `CardValidUntil` fijo `"2030-12-31"`; `VoucherNum` = `order.name` (máx. 20); `CreditSum` = `transaction.amount`; `CreditCur` `"CLP"`; `CreditType` `"cr_Regular"`; `NumOfPayments` y `SplitPayments` del config; `FirstPaymentDue` = `invoice.DocDate` |
|
||||||
|
| `TransferAccount` / `TransferSum` / `TransferDate` / `TransferReference` | solo si hay transacciones con `type = transfer` (reversso) | cuenta de `sap.payment_methods[gateway].transfer_account`; `TransferSum` = suma de esas transacciones; `TransferReference` = `order.name` |
|
||||||
|
| `Remarks` | gateways de las transacciones efectivas | join con `" + "`, sin repetir. **No** se usa `payment_gateway_names` (lista también los intentos fallidos) |
|
||||||
|
| `PaymentInvoices[0].DocEntry` | `invoice.DocEntry` | |
|
||||||
|
| `PaymentInvoices[0].InvoiceType` | — | fijo `"it_Invoice"` |
|
||||||
|
| `PaymentInvoices[0].SumApplied` | total de la Invoice | debe igualar la suma de las transacciones efectivas; si no, `error` y no se crea el pago. Ver `docs/casos_especiales.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `InventoryTransferRequests` (`domain/inventory_transfer.py`)
|
||||||
|
|
||||||
|
Una por cada `fulfillment` de la orden (tienda o CD).
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | |
|
||||||
|
| `Comments` | `order.name` | |
|
||||||
|
| `DocDate` / `DueDate` | `order.created_at` (solo fecha) | |
|
||||||
|
| `Series` | — | `sap.numbering_series.INVENTORY_TRANSFER_REQUEST` |
|
||||||
|
| `FromWarehouse` | `sap.locations[location_id].warehouse_code` | bodega real de ese `location_id` |
|
||||||
|
| `ToWarehouse` | — | `sap.default_warehouse_invoice` (misma bodega virtual del Invoice) |
|
||||||
|
| `U_ShopifyOrderId` / `U_ShopifyStore` | `order.id` / `store_code` | |
|
||||||
|
|
||||||
|
### `StockTransferLines` (una por `line_item` del fulfillment)
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `ItemCode` | `sku` del line_item del fulfillment | no toda la orden, solo lo de ese fulfillment |
|
||||||
|
| `Quantity` | `quantity` | |
|
||||||
|
| `FromWarehouseCode` | igual a `FromWarehouse` | |
|
||||||
|
| `WarehouseCode` | igual a `ToWarehouse` | |
|
||||||
|
|
||||||
|
`location_id → {is_store, warehouse_code}` sale de
|
||||||
|
`config/stores/<tienda>.json → sap.locations`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `StockTransfers` (`domain/stock_transfer.py`, solo si `is_store: true`)
|
||||||
|
|
||||||
|
Se arma a partir de la **ITR ya creada en SAP** (se relee completa para
|
||||||
|
tener `LineNum`).
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` / `CardName` / `Comments` | copiados de la ITR | |
|
||||||
|
| `DocDate` | `itr.DocDate` | |
|
||||||
|
| `FromWarehouse` / `ToWarehouse` | copiados de la ITR | mismo sentido que la solicitud |
|
||||||
|
| `Series` | — | `sap.numbering_series.STOCK_TRANSFER` |
|
||||||
|
| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la ITR | |
|
||||||
|
|
||||||
|
### `StockTransferLines` (una por línea de la ITR)
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `ItemCode` / `Quantity` / `FromWarehouseCode` / `WarehouseCode` | copiados de la línea de la ITR | |
|
||||||
|
| `BaseType` | — | `1250000001` (`DocObjectCode` de `InventoryTransferRequests`) |
|
||||||
|
| `BaseEntry` | `itr.DocEntry` | |
|
||||||
|
| `BaseLine` | `line.LineNum` de la ITR | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `DeliveryNotes` (`domain/delivery.py`, solo si `is_store: true`)
|
||||||
|
|
||||||
|
Se arma a partir de la **Invoice** (base) y el **StockTransfer** ya creado
|
||||||
|
(para saber qué se transfirió realmente en este fulfillment).
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | |
|
||||||
|
| `Comments` | `invoice.NumAtCard` | |
|
||||||
|
| `DocDate` | `invoice.DocDate` | |
|
||||||
|
| `Series` | — | `sap.numbering_series.DELIVERY` |
|
||||||
|
| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la Invoice | |
|
||||||
|
|
||||||
|
### `DocumentLines` (una por línea del StockTransfer, emparejada con la Invoice por `ItemCode`)
|
||||||
|
|
||||||
|
| Campo SAP | Origen | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `ItemCode` / `Quantity` | línea del StockTransfer | solo lo de este fulfillment, no toda la orden |
|
||||||
|
| `WarehouseCode` | línea de la Invoice con el mismo `ItemCode` | |
|
||||||
|
| `BaseType` | — | `13` (ObjType clásico de Sales Invoice, `oInvoices`) |
|
||||||
|
| `BaseEntry` | `invoice.DocEntry` | |
|
||||||
|
| `BaseLine` | `LineNum` de la línea de la Invoice | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referencias cruzadas en la Invoice (`agregar_referencia_documento`)
|
||||||
|
|
||||||
|
Cada vez que se crea ITR, StockTransfer o Delivery, se hace `PATCH
|
||||||
|
Invoices` agregando una entrada a `DocumentReferences` (sin pisar las que ya
|
||||||
|
existan):
|
||||||
|
|
||||||
|
| Documento creado | `RefObjType` |
|
||||||
|
|---|---|
|
||||||
|
| `InventoryTransferRequests` | `rot_InventoryTransferRequest` |
|
||||||
|
| `StockTransfers` | `rot_InventoryTransfer` |
|
||||||
|
| `DeliveryNotes` | `rot_DeliveryNotes` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Campos fijos / hardcode (pendientes de hacer configurables si hace falta)
|
||||||
|
|
||||||
|
| Campo | Valor | Dónde |
|
||||||
|
|---|---|---|
|
||||||
|
| `TaxCode` (líneas de Invoice) | `"IVA"` | `domain/invoice.py` |
|
||||||
|
| `TaxCode` (dirección ShipTo del BP) | `"IVA"` | `domain/business_partner.py` |
|
||||||
|
| `DocCurrency` | `"CLP"` | Invoice, Payment |
|
||||||
|
| Datos de tarjeta (Payment) | últimos dígitos `"1234"`, vencimiento `"2030-12-31"` | `domain/payment.py` (provisorios) |
|
||||||
|
| `GroupCode` (BusinessPartner) | `100` | `domain/business_partner.py` |
|
||||||
|
| `BaseType` ITR→StockTransfer | `1250000001` | `domain/stock_transfer.py` |
|
||||||
|
| `BaseType` Invoice→Delivery | `13` | `domain/delivery.py` |
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Pendientes / roadmap
|
||||||
|
|
||||||
|
Estado al 2026-09-30. Ver `docs/flujo_pipeline.md`, `docs/mapeo_campos.md` y
|
||||||
|
`docs/casos_especiales.md` para lo construido y probado contra SAP/Shopify reales
|
||||||
|
(ambas tiendas: snobbycl y on-chile-distributor-prod).
|
||||||
|
|
||||||
|
## Antes de pasar al QA del cliente
|
||||||
|
|
||||||
|
1. **Correr `main.py` solo** varios ciclos seguidos (el loop real del scheduler, no las
|
||||||
|
herramientas): lock, cursor, estados nuevos (`cancelled`, `ignored`), rotación de reintentos.
|
||||||
|
2. **Revisar la configuración de cada tienda contra el SAP de QA**: tarjetas de crédito
|
||||||
|
(`sap.payment_methods[*].credit_card`) y sus cuentas, series de numeración, bodegas
|
||||||
|
(`default_warehouse_invoice`, `locations`), `generic_card_code`, cuenta de reversso
|
||||||
|
(`1-1-010-20-000`), grupo de artículos (108), grupo de clientes (100), `TaxCode IVA`,
|
||||||
|
tipo de cambio USD cargado.
|
||||||
|
3. **Datos provisorios de tarjeta** (`domain/payment.py`): últimos dígitos `1234` y vencimiento
|
||||||
|
`2030-12-31`. Confirmar con contabilidad.
|
||||||
|
4. **Secretos**: ya están fuera del JSON (`.env` / variables de entorno). Falta definirlos en el
|
||||||
|
equipo de QA, y evaluar `verify_ssl: true` en `sap_connection`.
|
||||||
|
5. **Zona horaria**: el QA está en UTC-3 (America/Santiago) y el desarrollo en UTC-5. La BD guarda UTC y
|
||||||
|
`tools/consultar` muestra hora local (`APP_TIMEZONE`). **`main.py` usa la hora local del equipo**
|
||||||
|
para las ventanas de `scheduler.json`; hay que confirmar que el servidor de QA tenga su zona correcta
|
||||||
|
o fijar la zona en el scheduler.
|
||||||
|
6. **Limpieza de artefactos de prueba**: `download/`, respaldos de la BD en `data/`,
|
||||||
|
artículos y entradas de mercancía de prueba (precio 5000, cuenta `1-1-080-40-000`) de `SBODemoCL`.
|
||||||
|
7. **Repositorio git**: el proyecto **no es un repositorio** (no hay `.git`); el `.gitignore` ya existe. Falta
|
||||||
|
`git init`, primer commit y definir el remoto. Antes de subir, verificar que `git status` no incluya `.env`,
|
||||||
|
`data/*.db`, `logs/` ni `download/`.
|
||||||
|
8. ~~Fallback de órdenes manuales~~: retirado (carpeta `ordenes_manuales/` y `pipeline/manual_orders.py`); un error de
|
||||||
|
la API vuelve a verse como error de tienda.
|
||||||
|
9. **Gateways nuevos**: un gateway que no esté en `sap.payment_methods` solo se detecta al crear el pago (la
|
||||||
|
Invoice ya existe y la orden queda en `error`). Conviene validar los gateways de la orden **antes** de crear la
|
||||||
|
Invoice, o alertar apenas aparezca uno nuevo (hoy: `Venti`/`Transbank` en on-chile, `PayPal` configurado sin uso).
|
||||||
|
10. **Tests automáticos**: no hay carpeta de tests. Los JSON reales de `download/` sirven de datos para probar
|
||||||
|
`construir_invoice`, `construir_incoming_payment` y `fulfillments_facturados` sin tocar SAP.
|
||||||
|
|
||||||
|
## Decisiones de negocio abiertas
|
||||||
|
|
||||||
|
- **Pedidos de prueba**: hoy se marcan a mano como `ignored` (#8019). Definir si se detectan automáticamente (por
|
||||||
|
etiqueta, monto o cliente) para que no lleguen a SAP de QA. #8020 (1.000 CLP) se integró y podría ser de prueba.
|
||||||
|
- **Costo de envío**: toda orden con `total_shipping_price_set > 0` queda en `error` (no se definió cómo
|
||||||
|
reflejar el flete en la Invoice). En septiembre fue 1 de 1.160 órdenes en on-chile (#CL10169).
|
||||||
|
- **Reembolsos o anulaciones posteriores a integrar**: `integrated` es terminal; SAP queda con la venta
|
||||||
|
completa, sin nota de crédito ni devolución. Hoy el reembolso se trata como interno de comercial.
|
||||||
|
- **Envío real de la notificación de errores**: `generar_reporte_errores_html()` solo guarda el HTML en
|
||||||
|
`notifications/`. Falta definir a quién, con qué frecuencia y qué contenido.
|
||||||
|
- **Instalación como servicio de Windows**: falta empaquetarlo (ej. NSSM) en el equipo de destino.
|
||||||
|
|
||||||
|
## Datos que quedaron abiertos en la BD de desarrollo
|
||||||
|
|
||||||
|
- **on-chile — carga inicial**: 1.159 órdenes `pending` desde el 01/09 (1.049 elegibles, 73 sin despachar, 22 anuladas,
|
||||||
|
15 con pago pendiente, 1 con envío). Artículos y stock ya creados en `SBODemoCL` y gateways configurados; falta
|
||||||
|
integrarlas (en tandas, deteniéndose ante el primer error) y revisar #CL10851 (volvió a entrar como `pending`).
|
||||||
|
- **snobbycl — #7863**: queda en `error` (Delivery: documento base cerrado) y se reintenta en cada ciclo sin resolverse.
|
||||||
|
- **snobbycl — #8055**: pagada, sin despachar; se integra sola al despacharse.
|
||||||
|
- **Clientes**: integrar las órdenes de on-chile crea ~977 BusinessPartners nuevos en SAP (casi todas traen RUT).
|
||||||
|
|
||||||
|
## Operación
|
||||||
|
|
||||||
|
- **Respaldo y retención** de la BD SQLite (`data/shopify_sap.db`) y de `logs/` (hoy solo hay rotación de logs).
|
||||||
|
- **`docs/analisis_tiempos.md`** mide ~4-5 s por orden; en las pruebas de hoy fue ~5 s (CD) a ~7 s (tienda, con 5
|
||||||
|
documentos). Actualizar la estimación de capacidad (`max_orders_per_cycle`).
|
||||||
|
|
||||||
|
## Pruebas pendientes
|
||||||
|
|
||||||
|
- **Fulfillment parcial real** (`in_progress` que se completa en un ciclo posterior). Nunca se probó de punta
|
||||||
|
a punta con una orden real (hay órdenes `partial` en on-chile).
|
||||||
|
- **Desincronización SQLite/SAP**: la adopción de documentos existentes (ver abajo) está probada con la BD
|
||||||
|
bloqueada para escrituras; falta observarla en un ciclo real.
|
||||||
|
- **Volumen**: ~7 s por orden. La carga inicial de on-chile desde el 01/09 son ~1.150 órdenes (~2 horas).
|
||||||
|
|
||||||
|
## Ya resuelto (no repetir)
|
||||||
|
|
||||||
|
- Pipeline completo en ambas tiendas: Invoice, pago con tarjeta por transacción (incluye pagos divididos),
|
||||||
|
ITR, StockTransfer y Delivery; camino de tienda y de CD.
|
||||||
|
- Descarga real de cabeceras en ambas tiendas (scope `read_orders` aprobado en on-chile).
|
||||||
|
- **Idempotencia contra SAP**: antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento
|
||||||
|
existente de la orden (`U_ShopifyOrderId`, `U_ShopifyStore`, mismas líneas) y se adopta; una Invoice ya
|
||||||
|
pagada no genera otro pago.
|
||||||
|
- **Reintentos de red** en `ShopifyClient` (4 intentos con espera creciente).
|
||||||
|
- Invoice igual a lo cobrado (líneas canceladas o devueltas, reembolsos), fulfillments limitados a lo facturado,
|
||||||
|
órdenes anuladas (`cancelled`), pedidos ignorados (`ignored`), despachadas y luego reembolsadas se integran.
|
||||||
|
- Cursor con piso `initial_since_id`; corte de on-chile en el 01/09/2026.
|
||||||
|
- Secretos en `.env` (`core/config.py`), `.gitignore`, `requirements.txt` fijado.
|
||||||
|
- Fallback de órdenes manuales retirado (solo se usó mientras no se podía conectar a on-chile).
|
||||||
|
- Herramientas: `consultar`, `integrar_ordenes`, `probar_pago`, `descargar_ordenes_especiales`,
|
||||||
|
`entrada_mercancia_ordenes`, `cargar_items_faltantes`.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from domain.rut import formatear_rut, normalizar_rut, validar_rut
|
||||||
|
|
||||||
|
RUT_PATTERN = re.compile(r"^\d{1,2}\.?\d{3}\.?\d{3}-?[\dkK]$")
|
||||||
|
BUSINESS_PARTNER_GROUP_CODE = 100 # "Clientes", mismo grupo que usa el CardCode genérico
|
||||||
|
|
||||||
|
|
||||||
|
def _nombre_completo(persona: Dict[str, Any]) -> str:
|
||||||
|
nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip()
|
||||||
|
return nombre or persona.get("name", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def buscar_rut_en_order(order: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Busca el RUT en note_attributes de la orden, donde algunas tiendas lo
|
||||||
|
capturan en el checkout (ej. {"name": "rut", "value": "11111109-K"}).
|
||||||
|
No requiere llamada extra a Shopify: ya viene en el detalle de la orden.
|
||||||
|
"""
|
||||||
|
for attr in order.get("note_attributes") or []:
|
||||||
|
nombre = str(attr.get("name", "")).strip().lower()
|
||||||
|
if "rut" not in nombre:
|
||||||
|
continue
|
||||||
|
valor = str(attr.get("value", "")).strip()
|
||||||
|
if RUT_PATTERN.match(valor) and validar_rut(valor):
|
||||||
|
return normalizar_rut(valor)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bp_address(tipo: str, nombre: str, direccion: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
address = {
|
||||||
|
"AddressName": nombre,
|
||||||
|
"AddressType": tipo,
|
||||||
|
"Street": direccion.get("address1"),
|
||||||
|
"Block": direccion.get("address2"),
|
||||||
|
"City": direccion.get("city"),
|
||||||
|
"ZipCode": direccion.get("zip"),
|
||||||
|
"County": direccion.get("province"),
|
||||||
|
}
|
||||||
|
if direccion.get("country_code") == "CL":
|
||||||
|
address["Country"] = "CL"
|
||||||
|
if tipo == "bo_ShipTo":
|
||||||
|
address["TaxCode"] = "IVA"
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def crear_business_partner(sap: SAPServiceLayerClient, rut_sap: str, order: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Crea un BusinessPartner (cliente) nuevo para este RUT. CardCode = "C" +
|
||||||
|
rut_sap. Devuelve el CardCode creado.
|
||||||
|
"""
|
||||||
|
billing = order.get("billing_address") or {}
|
||||||
|
shipping = order.get("shipping_address") or {}
|
||||||
|
customer = order.get("customer") or {}
|
||||||
|
card_code = f"C{rut_sap}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"CardCode": card_code,
|
||||||
|
"CardName": _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify",
|
||||||
|
"CardType": "cCustomer",
|
||||||
|
"GroupCode": BUSINESS_PARTNER_GROUP_CODE,
|
||||||
|
"FederalTaxID": rut_sap,
|
||||||
|
"Phone1": billing.get("phone") or customer.get("phone"),
|
||||||
|
"EmailAddress": order.get("email") or order.get("contact_email"),
|
||||||
|
"BPAddresses": [
|
||||||
|
_bp_address("bo_BillTo", "BillTo", billing),
|
||||||
|
_bp_address("bo_ShipTo", "ShipTo", shipping or billing),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
sap.forward("POST", "BusinessPartners", json_data=payload)
|
||||||
|
return card_code
|
||||||
|
|
||||||
|
|
||||||
|
def resolver_card_code(
|
||||||
|
sap: SAPServiceLayerClient,
|
||||||
|
store_config: Dict[str, Any],
|
||||||
|
rut: Optional[str],
|
||||||
|
order: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Si hay un RUT válido, busca el BusinessPartner en SAP por FederalTaxID
|
||||||
|
(con guión, que es el formato que usa SAP: "NNNNNNNN-D"). Si existe,
|
||||||
|
devuelve su CardCode. Si no existe, crea uno nuevo (CardCode "C"+rut).
|
||||||
|
Si no hay RUT, o falla la creación, usa el CardCode genérico
|
||||||
|
configurado para la tienda.
|
||||||
|
|
||||||
|
Siempre devuelve "rut_sap" (el RUT en formato SAP) cuando hay RUT, para
|
||||||
|
poder sobrescribir el FederalTaxID del documento aunque se use el
|
||||||
|
CardCode genérico.
|
||||||
|
"""
|
||||||
|
generic_card_code = store_config["sap"]["generic_card_code"]
|
||||||
|
|
||||||
|
if not rut:
|
||||||
|
return {"card_code": generic_card_code, "rut": None, "rut_sap": None, "encontrado_por_rut": False}
|
||||||
|
|
||||||
|
rut_sap = formatear_rut(rut)
|
||||||
|
|
||||||
|
resp = sap.forward(
|
||||||
|
"GET",
|
||||||
|
"BusinessPartners",
|
||||||
|
params={"$select": "CardCode", "$filter": f"FederalTaxID eq '{rut_sap}'"},
|
||||||
|
)
|
||||||
|
resultados = resp.json().get("value", [])
|
||||||
|
|
||||||
|
if resultados:
|
||||||
|
return {"card_code": resultados[0]["CardCode"], "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True}
|
||||||
|
|
||||||
|
try:
|
||||||
|
card_code = crear_business_partner(sap, rut_sap, order or {})
|
||||||
|
return {"card_code": card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True}
|
||||||
|
except SAPServiceLayerError:
|
||||||
|
return {"card_code": generic_card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": False}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
INVOICE_BASE_TYPE = 13 # ObjType clásico de Sales Invoice (oInvoices) en SAP B1
|
||||||
|
|
||||||
|
|
||||||
|
def construir_delivery(invoice: Dict[str, Any], stock_transfer: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Arma el JSON para POST DeliveryNotes con la Invoice de reserva como base
|
||||||
|
(BaseType/BaseEntry/BaseLine). Solo se entregan los ítems que
|
||||||
|
efectivamente se transfirieron en este fulfillment (StockTransferLines),
|
||||||
|
emparejados con la línea correspondiente de la Invoice por ItemCode.
|
||||||
|
"""
|
||||||
|
invoice_lines_por_sku = {line["ItemCode"]: line for line in invoice["DocumentLines"]}
|
||||||
|
|
||||||
|
document_lines = []
|
||||||
|
for st_line in stock_transfer["StockTransferLines"]:
|
||||||
|
inv_line = invoice_lines_por_sku[st_line["ItemCode"]]
|
||||||
|
document_lines.append(
|
||||||
|
{
|
||||||
|
"ItemCode": st_line["ItemCode"],
|
||||||
|
"Quantity": st_line["Quantity"],
|
||||||
|
"WarehouseCode": inv_line["WarehouseCode"],
|
||||||
|
"BaseType": INVOICE_BASE_TYPE,
|
||||||
|
"BaseEntry": invoice["DocEntry"],
|
||||||
|
"BaseLine": inv_line["LineNum"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"CardCode": invoice.get("CardCode"),
|
||||||
|
"CardName": invoice.get("CardName"),
|
||||||
|
"Comments": invoice.get("NumAtCard"),
|
||||||
|
"DocDate": invoice["DocDate"][:10],
|
||||||
|
"Series": store_config["sap"]["numbering_series"]["DELIVERY"],
|
||||||
|
"U_ShopifyOrderId": invoice.get("U_ShopifyOrderId"),
|
||||||
|
"U_ShopifyStore": invoice.get("U_ShopifyStore"),
|
||||||
|
"DocumentLines": document_lines,
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from domain.items import identificar_skus_faltantes
|
||||||
|
|
||||||
|
|
||||||
|
def diagnosticar_error(sap: SAPServiceLayerClient, exc: SAPServiceLayerError, item_codes: Optional[List[str]] = None) -> str:
|
||||||
|
"""
|
||||||
|
Si el error de SAP corresponde a un tipo conocido, devuelve un mensaje
|
||||||
|
en lenguaje que el usuario entienda; si no, devuelve el mensaje
|
||||||
|
original de SAP tal cual (para no ocultar errores no identificados).
|
||||||
|
"""
|
||||||
|
if "exchange rate" in (exc.detail or "").lower():
|
||||||
|
moneda = re.search(r"'(\w+)'", exc.detail or "")
|
||||||
|
moneda = moneda.group(1) if moneda else "?"
|
||||||
|
return f"No hay tipo de cambio cargado en SAP para la moneda {moneda}"
|
||||||
|
|
||||||
|
if item_codes:
|
||||||
|
faltantes = identificar_skus_faltantes(sap, item_codes)
|
||||||
|
if faltantes:
|
||||||
|
return f"SKU inexistente(s) en SAP: {', '.join(faltantes)} (mensaje original de SAP: {exc.detail})"
|
||||||
|
|
||||||
|
return exc.detail
|
||||||
|
|
||||||
|
|
||||||
|
def buscar_documento_existente(
|
||||||
|
sap: SAPServiceLayerClient,
|
||||||
|
entidad: str,
|
||||||
|
store_code: str,
|
||||||
|
shopify_order_id: Any,
|
||||||
|
campo_lineas: str,
|
||||||
|
lineas_esperadas: Optional[List[Dict[str, Any]]],
|
||||||
|
excluir: Iterable[int] = (),
|
||||||
|
solo_vigentes: bool = False,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Resguardo de idempotencia contra SAP: busca un documento ya creado para
|
||||||
|
esta orden (UDFs U_ShopifyOrderId + U_ShopifyStore) cuyas líneas
|
||||||
|
(ItemCode, Quantity) coincidan con las que se iban a crear. Sirve cuando
|
||||||
|
la BD local perdió el registro de un documento que sí existe en SAP
|
||||||
|
(evita duplicarlo). Devuelve el documento completo o None.
|
||||||
|
`excluir`: DocEntry que ya están asignados a otro paso de la misma orden.
|
||||||
|
`solo_vigentes`: descarta anulados y documentos de anulación (Invoices).
|
||||||
|
Con `lineas_esperadas=None` se acepta el primer documento encontrado sin
|
||||||
|
comparar líneas (para la Invoice, que es única por orden).
|
||||||
|
"""
|
||||||
|
filtro = f"U_ShopifyOrderId eq '{shopify_order_id}' and U_ShopifyStore eq '{store_code}'"
|
||||||
|
if solo_vigentes:
|
||||||
|
filtro += " and CancelStatus eq 'csNo'"
|
||||||
|
candidatos = sap.forward("GET", entidad, params={"$filter": filtro, "$select": "DocEntry"}).json().get("value", [])
|
||||||
|
|
||||||
|
esperadas = Counter((l["ItemCode"], float(l["Quantity"])) for l in lineas_esperadas) if lineas_esperadas is not None else None
|
||||||
|
excluidos = set(excluir)
|
||||||
|
for candidato in sorted(candidatos, key=lambda c: c["DocEntry"]):
|
||||||
|
if candidato["DocEntry"] in excluidos:
|
||||||
|
continue
|
||||||
|
doc = sap.forward("GET", f"{entidad}({candidato['DocEntry']})").json()
|
||||||
|
if esperadas is None or Counter((l["ItemCode"], float(l["Quantity"])) for l in doc.get(campo_lineas, [])) == esperadas:
|
||||||
|
return doc
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def crear_documento_sap(
|
||||||
|
sap: SAPServiceLayerClient,
|
||||||
|
entidad: str,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
item_codes: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
POST genérico a cualquier entidad de documentos de SAP (Invoices,
|
||||||
|
IncomingPayments, InventoryTransferRequests, StockTransfers,
|
||||||
|
DeliveryNotes). Si falla, intenta identificar la causa real (ver
|
||||||
|
diagnosticar_error) antes de relanzar el error con ese mensaje; si no
|
||||||
|
se reconoce el error, se relanza el mensaje original de SAP.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = sap.forward("POST", entidad, json_data=payload)
|
||||||
|
return response.json()
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mensaje = diagnosticar_error(sap, exc, item_codes)
|
||||||
|
if mensaje != exc.detail:
|
||||||
|
raise SAPServiceLayerError(exc.status_code, mensaje, code=exc.code) from exc
|
||||||
|
raise
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def resolver_location(store_config: Dict[str, Any], location_id: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Config (is_store/warehouse_code) para un location_id de Shopify, o None si no está mapeado."""
|
||||||
|
return store_config["sap"]["locations"].get(str(location_id))
|
||||||
|
|
||||||
|
|
||||||
|
def es_tienda(store_config: Dict[str, Any], location_id: Any) -> bool:
|
||||||
|
"""
|
||||||
|
True si este location_id es una tienda física (genera StockTransfer +
|
||||||
|
Delivery además de la ITR); False si es un CD (solo ITR).
|
||||||
|
"""
|
||||||
|
location = resolver_location(store_config, location_id)
|
||||||
|
return bool(location and location.get("is_store"))
|
||||||
|
|
||||||
|
|
||||||
|
def construir_inventory_transfer_request(
|
||||||
|
order: Dict[str, Any],
|
||||||
|
fulfillment: Dict[str, Any],
|
||||||
|
store_config: Dict[str, Any],
|
||||||
|
warehouse_code: str,
|
||||||
|
invoice: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Arma el JSON para POST InventoryTransferRequests: una solicitud por cada
|
||||||
|
fulfillment, moviendo stock desde la bodega real del location_id hacia
|
||||||
|
la bodega virtual usada en el Invoice (default_warehouse_invoice). Usa
|
||||||
|
los line_items propios del fulfillment (no los de toda la orden), ya
|
||||||
|
que una orden puede tener varios fulfillments con distintos
|
||||||
|
ítems/cantidades. CardCode/CardName se toman del Invoice; Comments lleva
|
||||||
|
el "name" de Shopify para poder ubicarla fácil.
|
||||||
|
"""
|
||||||
|
to_warehouse = store_config["sap"]["default_warehouse_invoice"]
|
||||||
|
series = store_config["sap"]["numbering_series"]["INVENTORY_TRANSFER_REQUEST"]
|
||||||
|
doc_date = order["created_at"][:10]
|
||||||
|
|
||||||
|
document_lines = [
|
||||||
|
{
|
||||||
|
"ItemCode": item["sku"],
|
||||||
|
"Quantity": item["quantity"],
|
||||||
|
"FromWarehouseCode": warehouse_code,
|
||||||
|
"WarehouseCode": to_warehouse,
|
||||||
|
}
|
||||||
|
for item in fulfillment.get("line_items", [])
|
||||||
|
if item.get("sku")
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"CardCode": invoice["CardCode"],
|
||||||
|
"CardName": invoice["CardName"],
|
||||||
|
"Comments": order.get("name"),
|
||||||
|
"DocDate": doc_date,
|
||||||
|
"DueDate": doc_date,
|
||||||
|
"Series": series,
|
||||||
|
"FromWarehouse": warehouse_code,
|
||||||
|
"ToWarehouse": to_warehouse,
|
||||||
|
"U_ShopifyOrderId": order["id"],
|
||||||
|
"U_ShopifyStore": store_config["store_code"],
|
||||||
|
"StockTransferLines": document_lines,
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from domain.documents import crear_documento_sap
|
||||||
|
from domain.payment import transacciones_efectivas
|
||||||
|
|
||||||
|
|
||||||
|
def _nombre_completo(persona: Dict[str, Any]) -> str:
|
||||||
|
nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip()
|
||||||
|
return nombre or persona.get("name", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _precio_unitario_con_descuento(item: Dict[str, Any]) -> float:
|
||||||
|
"""
|
||||||
|
item["price"] es el precio unitario ANTES de descuento. El descuento
|
||||||
|
real de la línea viene en discount_allocations (total_discount no
|
||||||
|
siempre lo refleja). Se descuenta antes de armar PriceAfterVAT.
|
||||||
|
"""
|
||||||
|
cantidad = item.get("quantity") or 1
|
||||||
|
precio_total = float(item["price"]) * cantidad
|
||||||
|
descuento = sum(float(d.get("amount", 0)) for d in item.get("discount_allocations", []))
|
||||||
|
return round((precio_total - descuento) / cantidad, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _cantidad_cancelada(order: Dict[str, Any], line_item_id: Any) -> int:
|
||||||
|
"""
|
||||||
|
Unidades de una línea canceladas antes de la venta (refund_line_items con
|
||||||
|
restock_type "cancel", ej. líneas eliminadas de la orden antes del pago).
|
||||||
|
Las devoluciones posteriores a la entrega ("return") no se descuentan aquí.
|
||||||
|
"""
|
||||||
|
return sum(
|
||||||
|
int(rli.get("quantity") or 0)
|
||||||
|
for refund in order.get("refunds") or []
|
||||||
|
for rli in refund.get("refund_line_items") or []
|
||||||
|
if rli.get("line_item_id") == line_item_id and rli.get("restock_type") == "cancel"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _armar_lineas(order: Dict[str, Any], warehouse_code: str, cantidad_fn) -> List[Dict[str, Any]]:
|
||||||
|
lineas = []
|
||||||
|
for item in order.get("line_items", []):
|
||||||
|
cantidad = cantidad_fn(item)
|
||||||
|
if item.get("sku") and cantidad > 0:
|
||||||
|
lineas.append(
|
||||||
|
{
|
||||||
|
"ItemCode": item["sku"],
|
||||||
|
"Quantity": cantidad,
|
||||||
|
"WarehouseCode": warehouse_code,
|
||||||
|
"PriceAfterVAT": _precio_unitario_con_descuento(item),
|
||||||
|
"TaxCode": "IVA",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return lineas
|
||||||
|
|
||||||
|
|
||||||
|
def _total(lineas: List[Dict[str, Any]]) -> float:
|
||||||
|
return sum(l["Quantity"] * l["PriceAfterVAT"] for l in lineas)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineas_por_cobrado(
|
||||||
|
order: Dict[str, Any], warehouse_code: str, transactions: Optional[List[Dict[str, Any]]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
La Invoice debe igualar lo realmente cobrado (transacciones efectivas). Se
|
||||||
|
prueban dos juegos de líneas y se usa el que cuadre:
|
||||||
|
1. líneas originales (quantity): ventas normales y órdenes despachadas
|
||||||
|
que se reembolsaron después de cobrar el total (#CL10072, #8029);
|
||||||
|
2. líneas vigentes (current_quantity): líneas canceladas o devueltas que
|
||||||
|
nunca se cobraron (#7998, #CL10144).
|
||||||
|
Si ninguno cuadra se devuelven las originales y el control de suma del pago
|
||||||
|
deja la orden en error. Sin transacciones (API caída) se descuentan solo las
|
||||||
|
canceladas antes de la venta (restock_type "cancel").
|
||||||
|
"""
|
||||||
|
if transactions is None:
|
||||||
|
return _armar_lineas(order, warehouse_code, lambda i: i["quantity"] - _cantidad_cancelada(order, i.get("id")))
|
||||||
|
|
||||||
|
originales = _armar_lineas(order, warehouse_code, lambda i: i["quantity"])
|
||||||
|
cobrado = sum(float(t["amount"]) for t in transacciones_efectivas(transactions))
|
||||||
|
if abs(_total(originales) - cobrado) < 1:
|
||||||
|
return originales
|
||||||
|
|
||||||
|
vigentes = _armar_lineas(
|
||||||
|
order, warehouse_code, lambda i: i["current_quantity"] if i.get("current_quantity") is not None else i["quantity"]
|
||||||
|
)
|
||||||
|
if vigentes and abs(_total(vigentes) - cobrado) < 1:
|
||||||
|
return vigentes
|
||||||
|
return originales
|
||||||
|
|
||||||
|
|
||||||
|
def fulfillments_facturados(fulfillments: List[Dict[str, Any]], invoice: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Limita cada fulfillment a lo que quedó facturado: líneas devueltas o
|
||||||
|
canceladas que no se cobraron (ver _lineas_por_cobrado) no están en la
|
||||||
|
Invoice, y mover o entregar esas unidades rompería el Delivery (la línea
|
||||||
|
base no existe) o dejaría stock transferido sin venta. Por cada SKU se
|
||||||
|
permite como máximo la cantidad de la Invoice, repartida en orden entre los
|
||||||
|
fulfillments; los que quedan sin líneas se omiten.
|
||||||
|
"""
|
||||||
|
permitido: Dict[str, float] = {}
|
||||||
|
for line in invoice["DocumentLines"]:
|
||||||
|
permitido[line["ItemCode"]] = permitido.get(line["ItemCode"], 0) + line["Quantity"]
|
||||||
|
|
||||||
|
resultado = []
|
||||||
|
for fulfillment in fulfillments:
|
||||||
|
lineas = []
|
||||||
|
for item in fulfillment.get("line_items", []):
|
||||||
|
disponible = permitido.get(item.get("sku"), 0)
|
||||||
|
if disponible <= 0:
|
||||||
|
continue
|
||||||
|
cantidad = min(item["quantity"], disponible)
|
||||||
|
permitido[item["sku"]] = disponible - cantidad
|
||||||
|
lineas.append({**item, "quantity": cantidad})
|
||||||
|
if lineas:
|
||||||
|
resultado.append({**fulfillment, "line_items": lineas})
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
|
||||||
|
def _campos_direccion(prefijo: str, direccion: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not direccion:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
campos = {
|
||||||
|
f"{prefijo}Street": direccion.get("address1"),
|
||||||
|
f"{prefijo}Block": direccion.get("address2"),
|
||||||
|
f"{prefijo}Building": direccion.get("phone"),
|
||||||
|
f"{prefijo}City": direccion.get("city"),
|
||||||
|
f"{prefijo}ZipCode": direccion.get("zip"),
|
||||||
|
f"{prefijo}County": direccion.get("province"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if direccion.get("country_code") == "CL":
|
||||||
|
campos[f"{prefijo}Country"] = "CL"
|
||||||
|
|
||||||
|
return campos
|
||||||
|
|
||||||
|
|
||||||
|
def construir_invoice(
|
||||||
|
order: Dict[str, Any],
|
||||||
|
store_config: Dict[str, Any],
|
||||||
|
card_code: str,
|
||||||
|
federal_tax_id: Optional[str] = None,
|
||||||
|
transactions: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Arma el JSON para POST Invoices (factura de reserva). Las líneas se arman
|
||||||
|
por lo realmente cobrado (ver _lineas_por_cobrado). Sobrescribe
|
||||||
|
CardName y direcciones con los datos reales del cliente de Shopify,
|
||||||
|
aunque el CardCode sea el genérico. Si se conoce el RUT (federal_tax_id,
|
||||||
|
en formato SAP "NNNNNNNN-D"), también sobrescribe FederalTaxID, para que
|
||||||
|
el RUT real del cliente quede en la factura aunque se use el CardCode
|
||||||
|
genérico.
|
||||||
|
"""
|
||||||
|
customer = order.get("customer") or {}
|
||||||
|
billing = order.get("billing_address") or {}
|
||||||
|
shipping = order.get("shipping_address") or {}
|
||||||
|
|
||||||
|
card_name = _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify"
|
||||||
|
doc_date = order["created_at"][:10]
|
||||||
|
|
||||||
|
warehouse_code = store_config["sap"]["default_warehouse_invoice"]
|
||||||
|
series = store_config["sap"]["numbering_series"]["INVOICE"]
|
||||||
|
|
||||||
|
document_lines = _lineas_por_cobrado(order, warehouse_code, transactions)
|
||||||
|
|
||||||
|
address_extension = {
|
||||||
|
**_campos_direccion("BillTo", billing),
|
||||||
|
**_campos_direccion("ShipTo", shipping),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"CardCode": card_code,
|
||||||
|
"CardName": card_name,
|
||||||
|
"DocDate": doc_date,
|
||||||
|
"DocDueDate": doc_date,
|
||||||
|
"Series": series,
|
||||||
|
"DocType": "dDocument_Items",
|
||||||
|
"DocCurrency": "CLP",
|
||||||
|
"ReserveInvoice": "tYES",
|
||||||
|
"NumAtCard": order.get("name"),
|
||||||
|
"AddressExtension": address_extension,
|
||||||
|
"U_ShopifyOrderId": order["id"],
|
||||||
|
"U_ShopifyStore": store_config["store_code"],
|
||||||
|
"DocumentLines": document_lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
if federal_tax_id:
|
||||||
|
payload["FederalTaxID"] = federal_tax_id
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def crear_invoice(sap: SAPServiceLayerClient, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""POST Invoices, con diagnóstico de errores conocidos (ver domain/documents.py)."""
|
||||||
|
item_codes = [line["ItemCode"] for line in payload["DocumentLines"]]
|
||||||
|
return crear_documento_sap(sap, "Invoices", payload, item_codes=item_codes)
|
||||||
|
|
||||||
|
|
||||||
|
def agregar_referencia_documento(
|
||||||
|
sap: SAPServiceLayerClient,
|
||||||
|
invoice_doc_entry: int,
|
||||||
|
ref_doc_entry: int,
|
||||||
|
ref_doc_num: int,
|
||||||
|
ref_obj_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
PATCH a la Invoice para dejar registrada la referencia a un documento
|
||||||
|
generado a partir de ella (ITR, StockTransfer, etc.). Lee las
|
||||||
|
DocumentReferences actuales y agrega la nueva, sin pisar las que ya
|
||||||
|
existan (una orden puede tener varios fulfillments -> varios documentos).
|
||||||
|
"""
|
||||||
|
actual = sap.forward("GET", f"Invoices({invoice_doc_entry})", params={"$select": "DocumentReferences"})
|
||||||
|
referencias = actual.json().get("DocumentReferences", [])
|
||||||
|
|
||||||
|
referencias.append(
|
||||||
|
{
|
||||||
|
"RefDocEntr": ref_doc_entry,
|
||||||
|
"RefDocNum": ref_doc_num,
|
||||||
|
"RefObjType": ref_obj_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sap.forward("PATCH", f"Invoices({invoice_doc_entry})", json_data={"DocumentReferences": referencias})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import List
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
|
||||||
|
|
||||||
|
def _item_key(sku: str) -> str:
|
||||||
|
escaped = quote(sku.replace("'", "''"), safe="")
|
||||||
|
return f"Items('{escaped}')"
|
||||||
|
|
||||||
|
|
||||||
|
def item_existe(sap: SAPServiceLayerClient, sku: str) -> bool:
|
||||||
|
try:
|
||||||
|
sap.forward("GET", _item_key(sku))
|
||||||
|
return True
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def identificar_skus_faltantes(sap: SAPServiceLayerClient, skus: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Revisa cada SKU contra SAP (GET Items) y devuelve los que no existen.
|
||||||
|
Se usa cuando un POST de Invoice falla con el código -5009
|
||||||
|
("Item number is missing"), que no indica cuál ItemCode es el problema.
|
||||||
|
"""
|
||||||
|
return [sku for sku in skus if not item_existe(sap, sku)]
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
CARD_LAST_DIGITS = "1234"
|
||||||
|
CARD_VALID_UNTIL = "2030-12-31" # 12/30 para todas las tarjetas
|
||||||
|
VOUCHER_MAX_LEN = 20 # SAP rechaza VoucherNum más largo; VoucherNum = name de la orden de Shopify
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentConfigError(Exception):
|
||||||
|
"""El pago no se puede armar (medio sin configurar, montos que no cuadran, etc.)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def transacciones_efectivas(transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Transacciones que representan dinero realmente cobrado: status success y
|
||||||
|
kind sale/capture. Se ignoran failure/pending y refund/void. Si una
|
||||||
|
authorization tiene su capture (parent_id), solo cuenta la capture.
|
||||||
|
"""
|
||||||
|
exitosas = [t for t in transactions if t.get("status") == "success"]
|
||||||
|
autorizaciones_capturadas = {t.get("parent_id") for t in exitosas if t.get("kind") == "capture" and t.get("parent_id")}
|
||||||
|
|
||||||
|
efectivas = []
|
||||||
|
for t in exitosas:
|
||||||
|
if t.get("kind") == "sale":
|
||||||
|
efectivas.append(t)
|
||||||
|
elif t.get("kind") == "capture":
|
||||||
|
efectivas.append(t)
|
||||||
|
# authorization sin capture y el resto de kinds no suman
|
||||||
|
return [t for t in efectivas if t.get("id") not in autorizaciones_capturadas]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def _metodo(store_config: Dict[str, Any], gateway: str) -> Dict[str, Any]:
|
||||||
|
metodos = store_config["sap"].get("payment_methods") or {}
|
||||||
|
metodo = metodos.get(gateway)
|
||||||
|
if not metodo:
|
||||||
|
raise PaymentConfigError(f"Medio de pago '{gateway}' no está configurado en sap.payment_methods")
|
||||||
|
return metodo
|
||||||
|
|
||||||
|
|
||||||
|
def construir_incoming_payment(
|
||||||
|
order: Dict[str, Any],
|
||||||
|
transactions: List[Dict[str, Any]],
|
||||||
|
store_config: Dict[str, Any],
|
||||||
|
invoice: Dict[str, Any],
|
||||||
|
invoice_doc_entry: int,
|
||||||
|
invoice_total: float,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Arma el JSON para POST IncomingPayments, aplicado a la Invoice de reserva
|
||||||
|
ya creada (CardCode, CardName y fecha salen de esa Invoice).
|
||||||
|
|
||||||
|
Los medios de pago salen de las transacciones efectivas (no de
|
||||||
|
payment_gateway_names): cada una según sap.payment_methods[gateway]:
|
||||||
|
- credit_card -> una línea en PaymentCreditCards
|
||||||
|
- transfer -> suma a TransferSum con su transfer_account
|
||||||
|
La suma debe igualar el total de la Invoice.
|
||||||
|
"""
|
||||||
|
series = store_config["sap"]["numbering_series"]["INCOMING_PAYMENT"]
|
||||||
|
doc_date = invoice["DocDate"]
|
||||||
|
|
||||||
|
efectivas = transacciones_efectivas(transactions)
|
||||||
|
if not efectivas:
|
||||||
|
raise PaymentConfigError("La orden no tiene transacciones de pago exitosas")
|
||||||
|
|
||||||
|
tarjetas: List[Dict[str, Any]] = []
|
||||||
|
transferencia = Decimal("0")
|
||||||
|
cuentas_transfer = set()
|
||||||
|
gateways: List[str] = []
|
||||||
|
|
||||||
|
for t in efectivas:
|
||||||
|
gateway = t.get("gateway")
|
||||||
|
metodo = _metodo(store_config, gateway)
|
||||||
|
monto = Decimal(str(t["amount"]))
|
||||||
|
gateways.append(gateway)
|
||||||
|
|
||||||
|
if metodo["type"] == "credit_card":
|
||||||
|
if not metodo.get("credit_card"):
|
||||||
|
raise PaymentConfigError(f"Medio de pago '{gateway}' sin tarjeta de crédito (credit_card) asociada")
|
||||||
|
linea = {
|
||||||
|
"CreditCard": metodo["credit_card"],
|
||||||
|
"CreditCardNumber": CARD_LAST_DIGITS,
|
||||||
|
"CardValidUntil": CARD_VALID_UNTIL,
|
||||||
|
"VoucherNum": str(order.get("name") or "")[:VOUCHER_MAX_LEN],
|
||||||
|
"CreditSum": float(monto),
|
||||||
|
"CreditCur": "CLP",
|
||||||
|
"CreditType": "cr_Regular",
|
||||||
|
"NumOfPayments": metodo.get("num_of_payments", 1),
|
||||||
|
"FirstPaymentDue": doc_date,
|
||||||
|
"SplitPayments": "tYES" if metodo.get("split_payment") else "tNO",
|
||||||
|
}
|
||||||
|
if metodo.get("payment_method_code"):
|
||||||
|
linea["PaymentMethodCode"] = metodo["payment_method_code"]
|
||||||
|
tarjetas.append(linea)
|
||||||
|
elif metodo["type"] == "transfer":
|
||||||
|
if not metodo.get("transfer_account"):
|
||||||
|
raise PaymentConfigError(f"Medio de pago '{gateway}' sin transfer_account")
|
||||||
|
cuentas_transfer.add(metodo["transfer_account"])
|
||||||
|
transferencia += monto
|
||||||
|
else:
|
||||||
|
raise PaymentConfigError(f"Medio de pago '{gateway}' con type inválido: {metodo['type']!r}")
|
||||||
|
|
||||||
|
if len(cuentas_transfer) > 1:
|
||||||
|
raise PaymentConfigError(f"Transferencias con cuentas distintas no soportado: {sorted(cuentas_transfer)}")
|
||||||
|
|
||||||
|
total_pagado = transferencia + sum((Decimal(str(l["CreditSum"])) for l in tarjetas), Decimal("0"))
|
||||||
|
if total_pagado != Decimal(str(invoice_total)):
|
||||||
|
raise PaymentConfigError(f"Suma de pagos ({total_pagado}) no coincide con el total de la Invoice ({invoice_total})")
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"CardCode": invoice["CardCode"],
|
||||||
|
"CardName": invoice["CardName"],
|
||||||
|
"DocType": "rCustomer",
|
||||||
|
"DocDate": doc_date,
|
||||||
|
"Series": series,
|
||||||
|
"DocCurrency": "CLP",
|
||||||
|
"Remarks": " + ".join(dict.fromkeys(gateways)),
|
||||||
|
"PaymentInvoices": [
|
||||||
|
{
|
||||||
|
"DocEntry": invoice_doc_entry,
|
||||||
|
"InvoiceType": "it_Invoice",
|
||||||
|
"SumApplied": invoice_total,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if tarjetas:
|
||||||
|
payload["PaymentCreditCards"] = tarjetas
|
||||||
|
if transferencia:
|
||||||
|
payload["TransferAccount"] = next(iter(cuentas_transfer))
|
||||||
|
payload["TransferSum"] = float(transferencia)
|
||||||
|
payload["TransferDate"] = doc_date
|
||||||
|
payload["TransferReference"] = order.get("name")
|
||||||
|
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
def _limpiar(rut: str) -> str:
|
||||||
|
return rut.upper().replace(".", "").replace("-", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _calcular_dv(numero: str) -> str:
|
||||||
|
suma = 0
|
||||||
|
multiplicador = 2
|
||||||
|
for digito in reversed(numero):
|
||||||
|
suma += int(digito) * multiplicador
|
||||||
|
multiplicador = multiplicador + 1 if multiplicador < 7 else 2
|
||||||
|
|
||||||
|
resto = 11 - (suma % 11)
|
||||||
|
if resto == 11:
|
||||||
|
return "0"
|
||||||
|
if resto == 10:
|
||||||
|
return "K"
|
||||||
|
return str(resto)
|
||||||
|
|
||||||
|
|
||||||
|
def validar_rut(rut: str) -> bool:
|
||||||
|
"""
|
||||||
|
Valida un RUT chileno usando el dígito verificador módulo 11.
|
||||||
|
Acepta con o sin puntos/guión: "12345678-5", "12.345.678-5", "123456785".
|
||||||
|
"""
|
||||||
|
limpio = _limpiar(rut)
|
||||||
|
if len(limpio) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
numero, dv = limpio[:-1], limpio[-1]
|
||||||
|
if not numero.isdigit():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _calcular_dv(numero) == dv
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_rut(rut: str) -> str:
|
||||||
|
"""Devuelve el RUT sin puntos ni guión, en mayúsculas (ej. "123456785")."""
|
||||||
|
return _limpiar(rut)
|
||||||
|
|
||||||
|
|
||||||
|
def formatear_rut(rut: str) -> str:
|
||||||
|
"""
|
||||||
|
Devuelve el RUT en el formato que usa SAP para FederalTaxID:
|
||||||
|
"NNNNNNNN-D" (sin puntos, con guión antes del dígito verificador).
|
||||||
|
"""
|
||||||
|
limpio = _limpiar(rut)
|
||||||
|
return f"{limpio[:-1]}-{limpio[-1]}"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
ITR_BASE_TYPE = 1250000001 # DocObjectCode de InventoryTransferRequests
|
||||||
|
|
||||||
|
|
||||||
|
def construir_stock_transfer(itr: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Arma el JSON para POST StockTransfers a partir de una
|
||||||
|
InventoryTransferRequest ya creada en SAP (itr = el documento completo
|
||||||
|
leído de SAP, con LineNum reales). Copia CardCode/CardName/Comments/
|
||||||
|
almacenes/UDFs de la ITR, y cada línea referencia su línea base vía
|
||||||
|
BaseType/BaseEntry/BaseLine.
|
||||||
|
"""
|
||||||
|
document_lines = [
|
||||||
|
{
|
||||||
|
"ItemCode": line["ItemCode"],
|
||||||
|
"Quantity": line["Quantity"],
|
||||||
|
"FromWarehouseCode": line["FromWarehouseCode"],
|
||||||
|
"WarehouseCode": line["WarehouseCode"],
|
||||||
|
"BaseType": ITR_BASE_TYPE,
|
||||||
|
"BaseEntry": itr["DocEntry"],
|
||||||
|
"BaseLine": line["LineNum"],
|
||||||
|
}
|
||||||
|
for line in itr["StockTransferLines"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"CardCode": itr.get("CardCode"),
|
||||||
|
"CardName": itr.get("CardName"),
|
||||||
|
"Comments": itr.get("Comments"),
|
||||||
|
"DocDate": itr["DocDate"][:10],
|
||||||
|
"FromWarehouse": itr["FromWarehouse"],
|
||||||
|
"ToWarehouse": itr["ToWarehouse"],
|
||||||
|
"Series": store_config["sap"]["numbering_series"]["STOCK_TRANSFER"],
|
||||||
|
"U_ShopifyOrderId": itr.get("U_ShopifyOrderId"),
|
||||||
|
"U_ShopifyStore": itr.get("U_ShopifyStore"),
|
||||||
|
"StockTransferLines": document_lines,
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,144 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.db import init_db
|
||||||
|
from core.lock import SingleInstanceError, acquire_single_instance_lock
|
||||||
|
from core.logger_manager import get_logger
|
||||||
|
from pipeline.runner import ejecutar_ciclo
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
SCHEDULER_PATH = Path(__file__).resolve().parent / "scheduler.json"
|
||||||
|
|
||||||
|
config: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def cargar_programacion() -> dict:
|
||||||
|
global config
|
||||||
|
with open(SCHEDULER_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data["HORARIOS"] = [tuple(h) for h in data["HORARIOS"]]
|
||||||
|
config = data
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_horarios() -> list:
|
||||||
|
return config.get("HORARIOS", [])
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_dias_activos() -> list:
|
||||||
|
return config.get("DIAS_ACTIVOS", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
|
||||||
|
minutos = dt.hour * 60 + dt.minute
|
||||||
|
|
||||||
|
for hi, mi, hf, mf, intervalo, modo in obtener_horarios():
|
||||||
|
inicio = hi * 60 + mi
|
||||||
|
fin = hf * 60 + mf
|
||||||
|
|
||||||
|
if inicio <= minutos < fin:
|
||||||
|
return (intervalo, modo)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def segundos_hasta_proximo_inicio(dt: datetime) -> int:
|
||||||
|
"""
|
||||||
|
Revisa todos los horarios de hoy (no solo el primero) y toma el próximo
|
||||||
|
inicio que aún no haya pasado. Si ninguno queda hoy, salta al próximo
|
||||||
|
día activo.
|
||||||
|
"""
|
||||||
|
candidatos = []
|
||||||
|
for hi, mi, *_ in obtener_horarios():
|
||||||
|
inicio = dt.replace(hour=hi, minute=mi, second=0, microsecond=0)
|
||||||
|
if inicio > dt:
|
||||||
|
candidatos.append(inicio)
|
||||||
|
|
||||||
|
if candidatos:
|
||||||
|
return int((min(candidatos) - dt).total_seconds())
|
||||||
|
|
||||||
|
return segundos_hasta_proximo_dia_activo(dt)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def segundos_hasta_proximo_dia_activo(dt: datetime) -> int:
|
||||||
|
horarios = obtener_horarios()
|
||||||
|
hi, mi = (horarios[0][0], horarios[0][1]) if horarios else (6, 0)
|
||||||
|
dias_activos = obtener_dias_activos()
|
||||||
|
|
||||||
|
for dias in range(1, 8):
|
||||||
|
prox = dt + timedelta(days=dias)
|
||||||
|
if prox.weekday() in dias_activos:
|
||||||
|
target = prox.replace(hour=hi, minute=mi, second=0, microsecond=0)
|
||||||
|
return int((target - dt).total_seconds())
|
||||||
|
return 86400
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def main():
|
||||||
|
init_db()
|
||||||
|
cargar_programacion()
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Servicio Shopify -> SAP iniciado")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
inicio_ciclo = datetime.now()
|
||||||
|
cargar_programacion()
|
||||||
|
|
||||||
|
if inicio_ciclo.weekday() not in obtener_dias_activos():
|
||||||
|
dormir = segundos_hasta_proximo_dia_activo(inicio_ciclo)
|
||||||
|
logger.info(
|
||||||
|
f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Día no activo. "
|
||||||
|
f"Próxima ejecución: {(inicio_ciclo + timedelta(seconds=dormir)):%Y-%m-%d %H:%M:%S}"
|
||||||
|
)
|
||||||
|
time.sleep(dormir)
|
||||||
|
continue
|
||||||
|
|
||||||
|
resultado = obtener_intervalo(inicio_ciclo)
|
||||||
|
|
||||||
|
if resultado is not None:
|
||||||
|
intervalo, modo = resultado
|
||||||
|
logger.info(f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Iniciando ciclo [{modo}] (cada {intervalo}s)")
|
||||||
|
|
||||||
|
ejecutar_ciclo(modo)
|
||||||
|
|
||||||
|
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
||||||
|
if duracion > intervalo:
|
||||||
|
logger.warning(f"El ciclo demoró {duracion:.1f}s (intervalo configurado {intervalo}s)")
|
||||||
|
|
||||||
|
dormir = max(0, intervalo - duracion)
|
||||||
|
logger.info(f"Próximo ciclo [{modo}] en {dormir:.0f}s...")
|
||||||
|
else:
|
||||||
|
dormir = segundos_hasta_proximo_inicio(inicio_ciclo)
|
||||||
|
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
||||||
|
logger.info(
|
||||||
|
f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Fuera de horario. "
|
||||||
|
f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(dormir)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
lock = acquire_single_instance_lock()
|
||||||
|
except SingleInstanceError as exc:
|
||||||
|
logger.error(str(exc))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Servicio detenido por el usuario.")
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.db import (
|
||||||
|
ensure_order_document,
|
||||||
|
ensure_order_fulfillment,
|
||||||
|
get_doc_entries,
|
||||||
|
get_last_shopify_order_id,
|
||||||
|
get_order_document,
|
||||||
|
get_pending_orders,
|
||||||
|
insert_order_header,
|
||||||
|
mark_document_created,
|
||||||
|
mark_document_error,
|
||||||
|
mark_orders_timeout,
|
||||||
|
set_order_card_code,
|
||||||
|
set_order_status,
|
||||||
|
update_order_snapshot,
|
||||||
|
)
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.logger_manager import get_logger, order_context, log_event
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
from domain.business_partner import buscar_rut_en_order, resolver_card_code
|
||||||
|
from domain.delivery import construir_delivery
|
||||||
|
from domain.documents import buscar_documento_existente, crear_documento_sap
|
||||||
|
from domain.invoice import agregar_referencia_documento, construir_invoice, crear_invoice, fulfillments_facturados
|
||||||
|
from domain.inventory_transfer import construir_inventory_transfer_request, es_tienda, resolver_location
|
||||||
|
from domain.payment import PaymentConfigError, construir_incoming_payment
|
||||||
|
from domain.rut import formatear_rut
|
||||||
|
from domain.stock_transfer import construir_stock_transfer
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores"
|
||||||
|
|
||||||
|
|
||||||
|
def _registrar_adoptado(document_sys_id: int, entidad: str, doc: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Un documento que ya existía en SAP para esta orden (la BD local no lo tenía
|
||||||
|
registrado) se adopta en vez de crear uno nuevo, para no duplicarlo.
|
||||||
|
"""
|
||||||
|
mark_document_created(document_sys_id, doc["DocEntry"], doc["DocNum"])
|
||||||
|
mensaje = f"{entidad} DocNum {doc['DocNum']} ya existía en SAP; se adopta y no se crea otro"
|
||||||
|
logger.warning(mensaje)
|
||||||
|
log_event("documento_adoptado", level="warning", message=mensaje, tipo=entidad, doc_entry=doc["DocEntry"])
|
||||||
|
|
||||||
|
# Una orden pagada y despachada se integra aunque después se haya reembolsado
|
||||||
|
# (total o parcialmente): el reembolso es un tratamiento interno de comercial,
|
||||||
|
# no se refleja en SAP. Las anuladas (cancelled_at) se descartan antes.
|
||||||
|
ESTADOS_FINANCIEROS_FACTURABLES = ("paid", "partially_refunded", "refunded")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def cargar_tiendas() -> List[Dict[str, Any]]:
|
||||||
|
"""Lee todos los JSON de configuración en config/stores/."""
|
||||||
|
tiendas = []
|
||||||
|
for path in sorted(STORES_DIR.glob("*.json")):
|
||||||
|
if path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
tiendas.append(cargar_config_tienda(path))
|
||||||
|
return tiendas
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def descargar_cabeceras_nuevas(store_config: Dict[str, Any]) -> int:
|
||||||
|
"""
|
||||||
|
Descarga cabeceras nuevas desde Shopify (since_id = última cabecera
|
||||||
|
guardada para esta tienda) y las guarda en la tabla orders.
|
||||||
|
Devuelve la cantidad de cabeceras nuevas insertadas.
|
||||||
|
"""
|
||||||
|
store_code = store_config["store_code"]
|
||||||
|
shopify_config = store_config["shopify"]
|
||||||
|
|
||||||
|
# El punto de corte de la configuración actúa como piso: en la primera
|
||||||
|
# corrida (tabla vacía) evita traer todo el historial desde el id=0, y si
|
||||||
|
# se sube después, salta las órdenes anteriores aunque ya haya cabeceras
|
||||||
|
# más antiguas guardadas en la BD.
|
||||||
|
since_id = max(get_last_shopify_order_id(store_code), int(shopify_config.get("initial_since_id") or 0))
|
||||||
|
|
||||||
|
page_limit = shopify_config.get("page_limit", 50)
|
||||||
|
|
||||||
|
shopify = ShopifyClient.from_store_config(shopify_config)
|
||||||
|
ordenes = shopify.obtener_cabeceras_ordenes(since_id=since_id, limit=page_limit)
|
||||||
|
|
||||||
|
nuevas = 0
|
||||||
|
for orden in ordenes:
|
||||||
|
header = {
|
||||||
|
"shopify_order_id": orden["id"],
|
||||||
|
"order_number": orden.get("name"),
|
||||||
|
"tags": orden.get("tags"),
|
||||||
|
"shopify_created_at": orden.get("created_at"),
|
||||||
|
"financial_status": orden.get("financial_status"),
|
||||||
|
"fulfillment_status": orden.get("fulfillment_status"),
|
||||||
|
}
|
||||||
|
if insert_order_header(store_code, header):
|
||||||
|
nuevas += 1
|
||||||
|
|
||||||
|
logger.info(f"[{store_code}] Cabeceras nuevas descargadas: {nuevas} (since_id={since_id}, recibidas={len(ordenes)})")
|
||||||
|
log_event("headers_downloaded", store_code=store_code, since_id=since_id, count=nuevas)
|
||||||
|
return nuevas
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def obtener_ordenes_pendientes(store_config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Marca como timeout las órdenes vencidas de esta tienda y devuelve las
|
||||||
|
que aún no llegaron a un estado terminal (integrated/timeout).
|
||||||
|
"""
|
||||||
|
store_code = store_config["store_code"]
|
||||||
|
timeout_days = store_config.get("timeout_days", 30)
|
||||||
|
|
||||||
|
marcadas = mark_orders_timeout(store_code, timeout_days)
|
||||||
|
if marcadas:
|
||||||
|
logger.warning(f"[{store_code}] Órdenes marcadas como timeout: {marcadas}")
|
||||||
|
log_event("orders_timeout", store_code=store_code, count=marcadas)
|
||||||
|
|
||||||
|
return get_pending_orders(store_code)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def analizar_y_procesar_orden(store_config: Dict[str, Any], orden_row: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Descarga el detalle completo de la orden, valida paid+fulfillments,
|
||||||
|
resuelve el BusinessPartner, y crea/continúa (por sys_id de documento,
|
||||||
|
así se retoma donde quedó) Invoice -> Payment -> por cada fulfillment:
|
||||||
|
ITR -> (si es tienda) StockTransfer -> Delivery.
|
||||||
|
"""
|
||||||
|
store_code = store_config["store_code"]
|
||||||
|
order_sys_id = orden_row["sys_id"]
|
||||||
|
|
||||||
|
shopify = ShopifyClient.from_store_config(store_config["shopify"])
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||||
|
|
||||||
|
detalle = shopify.obtener_orden(orden_row["shopify_order_id"])
|
||||||
|
update_order_snapshot(order_sys_id, detalle)
|
||||||
|
|
||||||
|
if detalle.get("cancelled_at"):
|
||||||
|
# Orden anulada en Shopify: nunca se integra, aunque siga "paid" y con fulfillments.
|
||||||
|
motivo = f"Anulada en Shopify el {detalle['cancelled_at'][:10]}: {detalle.get('cancel_reason') or 'sin motivo'}"
|
||||||
|
set_order_status(order_sys_id, "cancelled", motivo)
|
||||||
|
log_event("order_cancelled", store_code=store_code, shopify_order_id=orden_row["shopify_order_id"], message=motivo)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if detalle.get("financial_status") not in ESTADOS_FINANCIEROS_FACTURABLES or not detalle.get("fulfillments"):
|
||||||
|
set_order_status(order_sys_id, "not_eligible")
|
||||||
|
return False
|
||||||
|
|
||||||
|
envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0)
|
||||||
|
if envio > 0:
|
||||||
|
mensaje = f"Orden tiene costo de envío ({envio}) y ese mapeo aún no está soportado"
|
||||||
|
logger.error(mensaje)
|
||||||
|
log_event("envio_no_soportado", level="error", message=mensaje, monto=envio)
|
||||||
|
set_order_status(order_sys_id, "error", mensaje)
|
||||||
|
return False
|
||||||
|
|
||||||
|
rut = buscar_rut_en_order(detalle)
|
||||||
|
rut_sap = formatear_rut(rut) if rut else None
|
||||||
|
|
||||||
|
card_code = orden_row.get("card_code")
|
||||||
|
if not card_code:
|
||||||
|
resuelto = resolver_card_code(sap, store_config, rut, order=detalle)
|
||||||
|
card_code = resuelto["card_code"]
|
||||||
|
set_order_card_code(order_sys_id, card_code)
|
||||||
|
|
||||||
|
# ---- Invoice ----
|
||||||
|
invoice_doc_sys_id = ensure_order_document(order_sys_id, "INVOICE", 10)
|
||||||
|
invoice_row = get_order_document(invoice_doc_sys_id)
|
||||||
|
if invoice_row["status"] == "created":
|
||||||
|
invoice = sap.forward("GET", f"Invoices({invoice_row['sap_doc_entry']})").json()
|
||||||
|
else:
|
||||||
|
transacciones = shopify.obtener_transacciones(orden_row["shopify_order_id"])
|
||||||
|
payload = construir_invoice(detalle, store_config, card_code, federal_tax_id=rut_sap, transactions=transacciones)
|
||||||
|
if not payload["DocumentLines"]:
|
||||||
|
mensaje = "Orden sin líneas vigentes (todas canceladas antes de la venta)"
|
||||||
|
logger.warning(mensaje)
|
||||||
|
set_order_status(order_sys_id, "not_eligible", mensaje)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
invoice = buscar_documento_existente(sap, "Invoices", store_code, detalle["id"], "DocumentLines", None, solo_vigentes=True)
|
||||||
|
if invoice:
|
||||||
|
_registrar_adoptado(invoice_doc_sys_id, "Invoices", invoice)
|
||||||
|
else:
|
||||||
|
invoice = crear_invoice(sap, payload)
|
||||||
|
mark_document_created(invoice_doc_sys_id, invoice["DocEntry"], invoice["DocNum"])
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mark_document_error(invoice_doc_sys_id, exc.detail)
|
||||||
|
log_event("invoice_error", level="error", message=exc.detail, payload=payload)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- Payment ----
|
||||||
|
payment_doc_sys_id = ensure_order_document(order_sys_id, "INCOMING_PAYMENT", 20)
|
||||||
|
payment_row = get_order_document(payment_doc_sys_id)
|
||||||
|
if payment_row["status"] != "created" and invoice.get("DocTotal") and invoice.get("PaidToDate", 0) >= invoice["DocTotal"] - 0.5:
|
||||||
|
# La Invoice ya figura pagada en SAP (el pago existe pero la BD local no lo registró): no se duplica.
|
||||||
|
mark_document_created(payment_doc_sys_id, None, None)
|
||||||
|
log_event("documento_adoptado", level="warning", message=f"Invoice {invoice['DocNum']} ya pagada en SAP; no se crea el pago", tipo="IncomingPayments")
|
||||||
|
elif payment_row["status"] != "created":
|
||||||
|
total = sum(line["Quantity"] * line["PriceAfterVAT"] for line in invoice["DocumentLines"])
|
||||||
|
try:
|
||||||
|
transacciones = shopify.obtener_transacciones(orden_row["shopify_order_id"])
|
||||||
|
payload = construir_incoming_payment(detalle, transacciones, store_config, invoice, invoice["DocEntry"], total)
|
||||||
|
except PaymentConfigError as exc:
|
||||||
|
mark_document_error(payment_doc_sys_id, str(exc))
|
||||||
|
log_event("payment_error", level="error", message=str(exc))
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = crear_documento_sap(sap, "IncomingPayments", payload)
|
||||||
|
mark_document_created(payment_doc_sys_id, data["DocEntry"], data["DocNum"])
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mark_document_error(payment_doc_sys_id, exc.detail)
|
||||||
|
log_event("payment_error", level="error", message=exc.detail, payload=payload)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- Por cada fulfillment ----
|
||||||
|
todo_ok = True
|
||||||
|
for fulfillment in fulfillments_facturados(detalle["fulfillments"], invoice):
|
||||||
|
location = resolver_location(store_config, fulfillment["location_id"])
|
||||||
|
if not location:
|
||||||
|
mensaje = f"location_id {fulfillment['location_id']} sin mapeo en config"
|
||||||
|
logger.error(mensaje)
|
||||||
|
log_event("location_sin_mapeo", level="error", message=mensaje, location_id=fulfillment["location_id"])
|
||||||
|
set_order_status(order_sys_id, "error", mensaje)
|
||||||
|
todo_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
fulfillment_sys_id = ensure_order_fulfillment(
|
||||||
|
order_sys_id,
|
||||||
|
fulfillment["id"],
|
||||||
|
str(fulfillment["location_id"]),
|
||||||
|
location["is_store"],
|
||||||
|
location["warehouse_code"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ITR (siempre)
|
||||||
|
itr_doc_sys_id = ensure_order_document(order_sys_id, "INVENTORY_TRANSFER_REQUEST", 30, fulfillment_sys_id)
|
||||||
|
itr_row = get_order_document(itr_doc_sys_id)
|
||||||
|
if itr_row["status"] == "created":
|
||||||
|
itr = sap.forward("GET", f"InventoryTransferRequests({itr_row['sap_doc_entry']})").json()
|
||||||
|
else:
|
||||||
|
payload = construir_inventory_transfer_request(detalle, fulfillment, store_config, location["warehouse_code"], invoice)
|
||||||
|
try:
|
||||||
|
itr = buscar_documento_existente(
|
||||||
|
sap, "InventoryTransferRequests", store_code, detalle["id"], "StockTransferLines", payload["StockTransferLines"],
|
||||||
|
excluir=get_doc_entries(order_sys_id, "INVENTORY_TRANSFER_REQUEST"),
|
||||||
|
)
|
||||||
|
if itr:
|
||||||
|
_registrar_adoptado(itr_doc_sys_id, "InventoryTransferRequests", itr)
|
||||||
|
else:
|
||||||
|
item_codes = [line["ItemCode"] for line in payload["StockTransferLines"]]
|
||||||
|
data = crear_documento_sap(sap, "InventoryTransferRequests", payload, item_codes=item_codes)
|
||||||
|
mark_document_created(itr_doc_sys_id, data["DocEntry"], data["DocNum"])
|
||||||
|
agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_InventoryTransferRequest")
|
||||||
|
itr = sap.forward("GET", f"InventoryTransferRequests({data['DocEntry']})").json()
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mark_document_error(itr_doc_sys_id, exc.detail)
|
||||||
|
log_event("itr_error", level="error", message=exc.detail, payload=payload)
|
||||||
|
todo_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not location["is_store"]:
|
||||||
|
continue # CD: no sigue a StockTransfer/Delivery
|
||||||
|
|
||||||
|
# StockTransfer (solo tienda)
|
||||||
|
st_doc_sys_id = ensure_order_document(order_sys_id, "STOCK_TRANSFER", 40, fulfillment_sys_id)
|
||||||
|
st_row = get_order_document(st_doc_sys_id)
|
||||||
|
if st_row["status"] == "created":
|
||||||
|
stock_transfer = sap.forward("GET", f"StockTransfers({st_row['sap_doc_entry']})").json()
|
||||||
|
else:
|
||||||
|
payload = construir_stock_transfer(itr, store_config)
|
||||||
|
try:
|
||||||
|
stock_transfer = buscar_documento_existente(
|
||||||
|
sap, "StockTransfers", store_code, detalle["id"], "StockTransferLines", payload["StockTransferLines"],
|
||||||
|
excluir=get_doc_entries(order_sys_id, "STOCK_TRANSFER"),
|
||||||
|
)
|
||||||
|
if stock_transfer:
|
||||||
|
_registrar_adoptado(st_doc_sys_id, "StockTransfers", stock_transfer)
|
||||||
|
else:
|
||||||
|
item_codes = [line["ItemCode"] for line in payload["StockTransferLines"]]
|
||||||
|
data = crear_documento_sap(sap, "StockTransfers", payload, item_codes=item_codes)
|
||||||
|
mark_document_created(st_doc_sys_id, data["DocEntry"], data["DocNum"])
|
||||||
|
agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_InventoryTransfer")
|
||||||
|
stock_transfer = sap.forward("GET", f"StockTransfers({data['DocEntry']})").json()
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mark_document_error(st_doc_sys_id, exc.detail)
|
||||||
|
log_event("stock_transfer_error", level="error", message=exc.detail, payload=payload)
|
||||||
|
todo_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Delivery (solo tienda, base la Invoice)
|
||||||
|
dn_doc_sys_id = ensure_order_document(order_sys_id, "DELIVERY", 50, fulfillment_sys_id)
|
||||||
|
dn_row = get_order_document(dn_doc_sys_id)
|
||||||
|
if dn_row["status"] != "created":
|
||||||
|
payload = construir_delivery(invoice, stock_transfer, store_config)
|
||||||
|
try:
|
||||||
|
entrega = buscar_documento_existente(
|
||||||
|
sap, "DeliveryNotes", store_code, detalle["id"], "DocumentLines", payload["DocumentLines"],
|
||||||
|
excluir=get_doc_entries(order_sys_id, "DELIVERY"),
|
||||||
|
)
|
||||||
|
if entrega:
|
||||||
|
_registrar_adoptado(dn_doc_sys_id, "DeliveryNotes", entrega)
|
||||||
|
else:
|
||||||
|
item_codes = [line["ItemCode"] for line in payload["DocumentLines"]]
|
||||||
|
data = crear_documento_sap(sap, "DeliveryNotes", payload, item_codes=item_codes)
|
||||||
|
mark_document_created(dn_doc_sys_id, data["DocEntry"], data["DocNum"])
|
||||||
|
agregar_referencia_documento(sap, invoice["DocEntry"], data["DocEntry"], data["DocNum"], "rot_DeliveryNotes")
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
mark_document_error(dn_doc_sys_id, exc.detail)
|
||||||
|
log_event("delivery_error", level="error", message=exc.detail, payload=payload)
|
||||||
|
todo_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not todo_ok:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if detalle.get("fulfillment_status") == "fulfilled":
|
||||||
|
set_order_status(order_sys_id, "integrated")
|
||||||
|
else:
|
||||||
|
set_order_status(order_sys_id, "in_progress")
|
||||||
|
|
||||||
|
log_event("order_processed", store_code=store_code, shopify_order_id=orden_row["shopify_order_id"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def procesar_tienda(store_config: Dict[str, Any], modo: str) -> bool:
|
||||||
|
store_code = store_config.get("store_code", "?")
|
||||||
|
logger.info(f"Procesando tienda {store_code} [{modo}]")
|
||||||
|
log_event("store_cycle_start", store_code=store_code, modo=modo)
|
||||||
|
|
||||||
|
descargar_cabeceras_nuevas(store_config)
|
||||||
|
pendientes = obtener_ordenes_pendientes(store_config)
|
||||||
|
|
||||||
|
max_ordenes = store_config.get("max_orders_per_cycle")
|
||||||
|
if max_ordenes:
|
||||||
|
pendientes = pendientes[:max_ordenes]
|
||||||
|
|
||||||
|
for orden_row in pendientes:
|
||||||
|
with order_context(store_code, orden_row.get("shopify_order_id")):
|
||||||
|
try:
|
||||||
|
analizar_y_procesar_orden(store_config, orden_row)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error inesperado procesando orden {orden_row.get('shopify_order_id')}: {exc}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def ejecutar_ciclo(modo: str) -> bool:
|
||||||
|
"""Punto de entrada que main.py llama en cada tick del scheduler."""
|
||||||
|
tiendas = cargar_tiendas()
|
||||||
|
if not tiendas:
|
||||||
|
logger.warning("No hay tiendas configuradas en config/stores/")
|
||||||
|
return True
|
||||||
|
|
||||||
|
for store_config in tiendas:
|
||||||
|
try:
|
||||||
|
procesar_tienda(store_config, modo)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error procesando tienda {store_config.get('store_code', '?')}: {exc}")
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
requests==2.34.2
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
filelock==4.0.3
|
||||||
|
truststore==0.10.4
|
||||||
|
tzdata==2026.4 # zoneinfo en Windows necesita la base de zonas horarias
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"HORARIOS": [
|
||||||
|
[6, 0, 13, 15, 600, "rapido"],
|
||||||
|
[13, 15, 14, 0, 1800, "full"],
|
||||||
|
[14, 0, 17, 30, 600, "rapido"],
|
||||||
|
[17, 30, 21, 30, 1800, "full"]
|
||||||
|
],
|
||||||
|
"DIAS_ACTIVOS": [0, 1, 2, 3, 4]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Variables para las peticiones de REST Client (tools/*.http). Copiar a tools/.env y completar.
|
||||||
|
SHOPIFY_API_ID=
|
||||||
|
SHOPIFY_STORE=
|
||||||
|
SHOPIFY_API_VERSION=
|
||||||
|
SHOPIFY_ACCESS_TOKEN=
|
||||||
|
# SAP (tools/sap.http), si se usa:
|
||||||
|
# SAP_URL=
|
||||||
|
# SAP_COMPANYDB=
|
||||||
|
# SAP_USER=
|
||||||
|
# SAP_PASSWORD=
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Utilidad puntual (no forma parte del pipeline recurrente): crea una Entrada
|
||||||
|
de Mercancía (InventoryGenEntries) en SAP con todos los SKU del grupo de
|
||||||
|
artículos indicado, para dejar el stock inicial en un almacén.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
.venv/bin/python tools/cargar_entrada_mercancia.py config/stores/snobbycl.json \
|
||||||
|
--warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 1000
|
||||||
|
|
||||||
|
Agregar --apply para ejecutar el POST real (por defecto es dry-run).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
|
||||||
|
ITEMS_GROUP_CODE = 108
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_skus_grupo(sap: SAPServiceLayerClient, group_code: int) -> List[str]:
|
||||||
|
resp = sap.forward(
|
||||||
|
"GET",
|
||||||
|
"Items",
|
||||||
|
params={"$select": "ItemCode", "$filter": f"ItemsGroupCode eq {group_code}"},
|
||||||
|
extra_headers={"B1S-PageSize": "1000"},
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
return [i["ItemCode"] for i in data.get("value", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, dry_run: bool) -> None:
|
||||||
|
store_config = cargar_config_tienda(store_config_path)
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||||
|
|
||||||
|
skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE)
|
||||||
|
print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}")
|
||||||
|
|
||||||
|
document_lines = [
|
||||||
|
{
|
||||||
|
"ItemCode": sku,
|
||||||
|
"WarehouseCode": warehouse,
|
||||||
|
"Quantity": quantity,
|
||||||
|
"UnitPrice": price,
|
||||||
|
"AccountCode": account,
|
||||||
|
}
|
||||||
|
for sku in skus
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = {"DocumentLines": document_lines}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(json.dumps(payload["DocumentLines"][:3], indent=2, ensure_ascii=False))
|
||||||
|
print(f"... ({len(document_lines)} líneas en total, almacén={warehouse}, precio={price}, cuenta={account}, cantidad={quantity})")
|
||||||
|
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = sap.forward("POST", "InventoryGenEntries", json_data=payload)
|
||||||
|
data = resp.json()
|
||||||
|
print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
print(f"ERROR creando la entrada de mercancía: {exc.detail}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("store_config")
|
||||||
|
parser.add_argument("--warehouse", required=True)
|
||||||
|
parser.add_argument("--price", type=float, required=True)
|
||||||
|
parser.add_argument("--account", required=True)
|
||||||
|
parser.add_argument("--quantity", type=float, required=True)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
main(args.store_config, args.warehouse, args.price, args.account, args.quantity, dry_run=not args.apply)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Utilidad puntual (no forma parte del pipeline recurrente): recorre las
|
||||||
|
órdenes ya descargadas en SQLite para una tienda, trae el detalle completo
|
||||||
|
de cada una desde Shopify, extrae SKU + nombre de line_items, deduplica por
|
||||||
|
SKU, y crea en SAP los Items faltantes (grupo 108, artículo de venta +
|
||||||
|
inventario).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
.venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json
|
||||||
|
.venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.db import db_session
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
from domain.items import item_existe
|
||||||
|
|
||||||
|
ITEMS_GROUP_CODE = 108
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_shopify_order_ids(store_code: str) -> List[int]:
|
||||||
|
with db_session() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT shopify_order_id FROM orders WHERE store_code = ? ORDER BY shopify_order_id",
|
||||||
|
(store_code,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["shopify_order_id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def extraer_skus(shopify: ShopifyClient, order_ids: List[int]) -> Dict[str, str]:
|
||||||
|
"""Devuelve {sku: nombre} deduplicado, recorriendo el detalle de cada orden."""
|
||||||
|
skus: Dict[str, str] = {}
|
||||||
|
for idx, order_id in enumerate(order_ids, start=1):
|
||||||
|
detalle = shopify.obtener_orden(order_id)
|
||||||
|
for item in detalle.get("line_items", []):
|
||||||
|
sku = (item.get("sku") or "").strip()
|
||||||
|
nombre = item.get("name") or item.get("title") or sku
|
||||||
|
if sku and sku not in skus:
|
||||||
|
skus[sku] = nombre
|
||||||
|
print(f" [{idx}/{len(order_ids)}] orden {order_id}: {len(detalle.get('line_items', []))} line_items")
|
||||||
|
return skus
|
||||||
|
|
||||||
|
|
||||||
|
def crear_item(sap: SAPServiceLayerClient, sku: str, nombre: str) -> None:
|
||||||
|
payload = {
|
||||||
|
"ItemCode": sku,
|
||||||
|
"ItemName": nombre[:100],
|
||||||
|
"ItemsGroupCode": ITEMS_GROUP_CODE,
|
||||||
|
"SalesItem": "tYES",
|
||||||
|
"InventoryItem": "tYES",
|
||||||
|
"PurchaseItem": "tNO",
|
||||||
|
}
|
||||||
|
sap.forward("POST", "Items", json_data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def main(store_config_path: str, dry_run: bool) -> None:
|
||||||
|
store_config = cargar_config_tienda(store_config_path)
|
||||||
|
store_code = store_config["store_code"]
|
||||||
|
|
||||||
|
shopify = ShopifyClient.from_store_config(store_config["shopify"])
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||||
|
|
||||||
|
order_ids = obtener_shopify_order_ids(store_code)
|
||||||
|
print(f"Órdenes a revisar: {len(order_ids)}")
|
||||||
|
|
||||||
|
skus = extraer_skus(shopify, order_ids)
|
||||||
|
print(f"\nSKUs únicos encontrados: {len(skus)}\n")
|
||||||
|
|
||||||
|
existentes, creados, errores = 0, 0, 0
|
||||||
|
for sku, nombre in skus.items():
|
||||||
|
if item_existe(sap, sku):
|
||||||
|
existentes += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"[DRY RUN] Crearía: {sku!r} - {nombre!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
crear_item(sap, sku, nombre)
|
||||||
|
creados += 1
|
||||||
|
print(f"Creado: {sku} - {nombre}")
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
errores += 1
|
||||||
|
print(f"ERROR creando {sku}: {exc.detail}")
|
||||||
|
|
||||||
|
faltantes = len(skus) - existentes
|
||||||
|
print(f"\nResumen: ya existían={existentes} | faltantes detectados={faltantes} | creados={creados} | errores={errores}")
|
||||||
|
if dry_run:
|
||||||
|
print("(dry-run: no se escribió nada en SAP, corré con --apply para crear los items)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("store_config", help="Ruta al JSON de configuración de la tienda")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
main(args.store_config, dry_run=not args.apply)
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Consultas de solo lectura sobre data/shopify_sap.db (y logs/events.jsonl).
|
||||||
|
|
||||||
|
Uso (desde la raíz del proyecto):
|
||||||
|
.venv/bin/python -m tools.consultar resumen
|
||||||
|
.venv/bin/python -m tools.consultar descargadas [--store snobbycl] [--limit 50]
|
||||||
|
.venv/bin/python -m tools.consultar integradas [--store snobbycl] [--limit 50]
|
||||||
|
.venv/bin/python -m tools.consultar errores [--store snobbycl] [--limit 50]
|
||||||
|
.venv/bin/python -m tools.consultar orden --name "#8064" [--store snobbycl]
|
||||||
|
|
||||||
|
- resumen: órdenes por tienda y estado, con barras.
|
||||||
|
- descargadas: últimas órdenes descargadas (por fecha de descarga).
|
||||||
|
- integradas: últimas órdenes integradas, con los documentos SAP creados.
|
||||||
|
- errores: órdenes en error, documentos con error y eventos de error del log.
|
||||||
|
|
||||||
|
Las horas de la BD (UTC) se muestran en hora local (APP_TIMEZONE o la del sistema); las del log de eventos ya vienen en hora local.
|
||||||
|
- orden: cabecera, fulfillments y todos los pasos (documentos) de una orden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Sequence
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from core.config import cargar_env
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = ROOT / "data" / "shopify_sap.db"
|
||||||
|
EVENTS_PATH = ROOT / "logs" / "events.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def _zona():
|
||||||
|
cargar_env()
|
||||||
|
nombre = os.getenv("APP_TIMEZONE")
|
||||||
|
return ZoneInfo(nombre) if nombre else None # None = zona horaria del sistema
|
||||||
|
|
||||||
|
|
||||||
|
def hora_local(valor: Any) -> Any:
|
||||||
|
"""
|
||||||
|
La BD guarda time_insert/time_update en UTC (strftime 'now'); se muestran en hora local.
|
||||||
|
Zona: variable APP_TIMEZONE (ej. America/Santiago) si está definida; si no, la del sistema.
|
||||||
|
"""
|
||||||
|
if not valor:
|
||||||
|
return valor
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(str(valor), "%Y-%m-%d %H:%M:%S.%f" if "." in str(valor) else "%Y-%m-%d %H:%M:%S")
|
||||||
|
return dt.replace(tzinfo=timezone.utc).astimezone(_zona()).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
return valor
|
||||||
|
|
||||||
|
|
||||||
|
def conectar() -> sqlite3.Connection:
|
||||||
|
# Solo lectura: no bloquea ni modifica la BD aunque el scheduler esté corriendo.
|
||||||
|
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def tabla(filas: Sequence[Sequence[Any]], columnas: List[str], max_ancho: int = 60) -> None:
|
||||||
|
if not filas:
|
||||||
|
print("(sin resultados)")
|
||||||
|
return
|
||||||
|
celdas = [[("" if v is None else str(v))[:max_ancho] for v in fila] for fila in filas]
|
||||||
|
anchos = [max(len(c), *(len(f[i]) for f in celdas)) for i, c in enumerate(columnas)]
|
||||||
|
linea = " ".join(c.ljust(anchos[i]) for i, c in enumerate(columnas))
|
||||||
|
print(linea)
|
||||||
|
print(" ".join("-" * a for a in anchos))
|
||||||
|
for f in celdas:
|
||||||
|
print(" ".join(v.ljust(anchos[i]) for i, v in enumerate(f)))
|
||||||
|
print(f"\n{len(filas)} fila(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def filtro_tienda(store: str | None, alias: str = "o") -> tuple[str, list]:
|
||||||
|
return (f"AND {alias}.store_code = ?", [store]) if store else ("", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def resumen(conn: sqlite3.Connection, args) -> None:
|
||||||
|
filas = conn.execute(
|
||||||
|
"SELECT store_code, status, COUNT(*) n FROM orders GROUP BY store_code, status ORDER BY store_code, n DESC"
|
||||||
|
).fetchall()
|
||||||
|
maximo = max((r["n"] for r in filas), default=1)
|
||||||
|
salida = [(r["store_code"], r["status"], r["n"], "█" * max(1, round(r["n"] / maximo * 40))) for r in filas]
|
||||||
|
tabla(salida, ["tienda", "estado", "órdenes", ""])
|
||||||
|
|
||||||
|
|
||||||
|
def descargadas(conn: sqlite3.Connection, args) -> None:
|
||||||
|
extra, params = filtro_tienda(args.store)
|
||||||
|
filas = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT o.time_insert, o.store_code, o.order_number, o.shopify_order_id, o.financial_status,
|
||||||
|
o.fulfillment_status, o.status
|
||||||
|
FROM orders o WHERE 1=1 {extra}
|
||||||
|
ORDER BY o.time_insert DESC, o.sys_id DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
params + [args.limit],
|
||||||
|
).fetchall()
|
||||||
|
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["descargada (local)", "tienda", "orden", "shopify_id", "financiero", "fulfillment", "estado"])
|
||||||
|
|
||||||
|
|
||||||
|
def integradas(conn: sqlite3.Connection, args) -> None:
|
||||||
|
extra, params = filtro_tienda(args.store)
|
||||||
|
filas = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT o.time_update, o.store_code, o.order_number, o.card_code,
|
||||||
|
(SELECT group_concat(d.doc_type || ' ' || d.sap_doc_num, ' | ')
|
||||||
|
FROM order_documents d WHERE d.order_sys_id = o.sys_id AND d.status = 'created') docs
|
||||||
|
FROM orders o WHERE o.status = 'integrated' {extra}
|
||||||
|
ORDER BY o.time_update DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
params + [args.limit],
|
||||||
|
).fetchall()
|
||||||
|
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["integrada (local)", "tienda", "orden", "cliente", "documentos SAP"], max_ancho=110)
|
||||||
|
|
||||||
|
|
||||||
|
def errores(conn: sqlite3.Connection, args) -> None:
|
||||||
|
extra, params = filtro_tienda(args.store)
|
||||||
|
print("== Órdenes en estado error ==")
|
||||||
|
filas = conn.execute(
|
||||||
|
f"SELECT time_update, store_code, order_number, last_error FROM orders o WHERE status = 'error' {extra} ORDER BY time_update DESC LIMIT ?",
|
||||||
|
params + [args.limit],
|
||||||
|
).fetchall()
|
||||||
|
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizada (local)", "tienda", "orden", "último error"], max_ancho=110)
|
||||||
|
|
||||||
|
print("\n== Documentos con error ==")
|
||||||
|
filas = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT d.time_update, o.store_code, o.order_number, d.doc_type, d.attempts, d.error_message
|
||||||
|
FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id
|
||||||
|
WHERE d.status = 'error' {extra} ORDER BY d.time_update DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
params + [args.limit],
|
||||||
|
).fetchall()
|
||||||
|
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizado (local)", "tienda", "orden", "documento", "intentos", "error"], max_ancho=90)
|
||||||
|
|
||||||
|
print("\n== Eventos de error recientes (logs/events.jsonl) ==")
|
||||||
|
eventos = []
|
||||||
|
if EVENTS_PATH.exists():
|
||||||
|
for linea in EVENTS_PATH.read_text(encoding="utf-8").splitlines():
|
||||||
|
try:
|
||||||
|
e = json.loads(linea)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if e.get("level") in ("ERROR", "CRITICAL") and (not args.store or e.get("store_code") == args.store):
|
||||||
|
eventos.append((e.get("timestamp"), e.get("store_code"), e.get("shopify_order_id"), e.get("event"), e.get("message")))
|
||||||
|
tabla(eventos[-args.limit:][::-1], ["cuándo", "tienda", "shopify_id", "evento", "mensaje"], max_ancho=90)
|
||||||
|
|
||||||
|
|
||||||
|
def orden(conn: sqlite3.Connection, args) -> None:
|
||||||
|
extra, params = filtro_tienda(args.store)
|
||||||
|
nombre = args.name if args.name.startswith("#") else f"#{args.name}"
|
||||||
|
ordenes = conn.execute(f"SELECT * FROM orders o WHERE o.order_number = ? {extra}", [nombre] + params).fetchall()
|
||||||
|
if not ordenes:
|
||||||
|
print(f"No hay órdenes con name {nombre}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for o in ordenes:
|
||||||
|
print(f"\n===== {o['order_number']} | {o['store_code']} | shopify_id {o['shopify_order_id']} =====")
|
||||||
|
for campo in ("status", "last_error", "financial_status", "fulfillment_status", "payment_gateway_names",
|
||||||
|
"card_code", "tags", "shopify_created_at", "time_insert", "time_update"):
|
||||||
|
print(f" {campo:<22} {hora_local(o[campo]) if campo in ('time_insert', 'time_update') else o[campo]}")
|
||||||
|
|
||||||
|
print("\n Fulfillments")
|
||||||
|
fulfillments = conn.execute("SELECT * FROM order_fulfillments WHERE order_sys_id = ?", (o["sys_id"],)).fetchall()
|
||||||
|
tabla([(f["sys_id"], f["shopify_fulfillment_id"], f["location_id"], "tienda" if f["is_store"] else "CD", f["warehouse_code"])
|
||||||
|
for f in fulfillments], ["id", "shopify_fulfillment", "location", "tipo", "bodega"])
|
||||||
|
|
||||||
|
print("\n Pasos (documentos SAP)")
|
||||||
|
docs = conn.execute(
|
||||||
|
"SELECT * FROM order_documents WHERE order_sys_id = ? ORDER BY sequence, fulfillment_sys_id, sys_id", (o["sys_id"],)
|
||||||
|
).fetchall()
|
||||||
|
tabla([(d["sequence"], d["doc_type"], d["fulfillment_sys_id"], d["status"], d["sap_doc_entry"], d["sap_doc_num"],
|
||||||
|
d["attempts"], hora_local(d["time_update"]), d["error_message"]) for d in docs],
|
||||||
|
["seq", "documento", "fulfillment", "estado", "DocEntry", "DocNum", "intentos", "actualizado (local)", "error"], max_ancho=70)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Consultas de solo lectura sobre la BD de Shopify → SAP")
|
||||||
|
sub = parser.add_subparsers(dest="comando", required=True)
|
||||||
|
sub.add_parser("resumen")
|
||||||
|
for nombre in ("descargadas", "integradas", "errores"):
|
||||||
|
p = sub.add_parser(nombre)
|
||||||
|
p.add_argument("--store")
|
||||||
|
p.add_argument("--limit", type=int, default=50)
|
||||||
|
p = sub.add_parser("orden")
|
||||||
|
p.add_argument("--name", required=True)
|
||||||
|
p.add_argument("--store")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
with conectar() as conn:
|
||||||
|
{"resumen": resumen, "descargadas": descargadas, "integradas": integradas, "errores": errores, "orden": orden}[args.comando](conn, args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Descarga a download/ el JSON de Shopify (orden + transacciones) de los casos
|
||||||
|
especiales de una tienda, para analizarlos aparte. Solo lectura sobre Shopify
|
||||||
|
y la BD; solo escribe archivos locales en download/.
|
||||||
|
|
||||||
|
Se consideran especiales las órdenes de la BD que no están integradas (pending,
|
||||||
|
error, not_eligible, in_progress) y que, al traer el detalle, cumplen alguno de:
|
||||||
|
- tienen refunds
|
||||||
|
- financial_status distinto de paid
|
||||||
|
- no tienen fulfillments
|
||||||
|
- tienen costo de envío > 0
|
||||||
|
- están en status error en la BD
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
.venv/bin/python -m tools.descargar_ordenes_especiales <store_code>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.db import db_session
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DOWNLOAD_DIR = ROOT / "download"
|
||||||
|
|
||||||
|
|
||||||
|
def motivos(detalle: dict, status_bd: str) -> list[str]:
|
||||||
|
razones = []
|
||||||
|
if detalle.get("refunds"):
|
||||||
|
razones.append("reembolsos")
|
||||||
|
if detalle.get("financial_status") != "paid":
|
||||||
|
razones.append(f"financial_status={detalle.get('financial_status')}")
|
||||||
|
if not detalle.get("fulfillments"):
|
||||||
|
razones.append("sin fulfillments")
|
||||||
|
envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0)
|
||||||
|
if envio > 0:
|
||||||
|
razones.append(f"envío={envio:g}")
|
||||||
|
if status_bd == "error":
|
||||||
|
razones.append("status error en BD")
|
||||||
|
return razones
|
||||||
|
|
||||||
|
|
||||||
|
def main(store_code: str) -> None:
|
||||||
|
cfg = cargar_config_tienda(ROOT / "config" / "stores" / f"{store_code}.json")
|
||||||
|
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||||
|
|
||||||
|
with db_session() as conn:
|
||||||
|
filas = conn.execute(
|
||||||
|
"SELECT shopify_order_id, order_number, status FROM orders WHERE store_code = ? AND status != 'integrated' "
|
||||||
|
"AND status != 'timeout' ORDER BY shopify_order_id",
|
||||||
|
(store_code,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
DOWNLOAD_DIR.mkdir(exist_ok=True)
|
||||||
|
guardadas = []
|
||||||
|
for f in filas:
|
||||||
|
detalle = shopify.obtener_orden(f["shopify_order_id"])
|
||||||
|
razones = motivos(detalle, f["status"])
|
||||||
|
if not razones:
|
||||||
|
continue
|
||||||
|
nombre = detalle.get("name")
|
||||||
|
transacciones = shopify.obtener_transacciones(f["shopify_order_id"])
|
||||||
|
json.dump({"order": detalle}, open(DOWNLOAD_DIR / f"shopify_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
|
||||||
|
json.dump({"transactions": transacciones}, open(DOWNLOAD_DIR / f"shopify_transactions_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
|
||||||
|
guardadas.append((nombre, f["status"], "; ".join(razones)))
|
||||||
|
|
||||||
|
print(f"{store_code}: {len(filas)} órdenes revisadas, {len(guardadas)} casos especiales guardados en download/")
|
||||||
|
for g in guardadas:
|
||||||
|
print(f" {g[0]:<8} BD={g[1]:<12} {g[2]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
main(sys.argv[1])
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Entrada de Mercancía (InventoryGenEntries) solo con los SKUs de órdenes
|
||||||
|
puntuales de Shopify (no todo el grupo de artículos). DRY-RUN por defecto.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
.venv/bin/python -m tools.entrada_mercancia_ordenes config/stores/snobbycl.json \
|
||||||
|
--warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 2 \
|
||||||
|
--orders 18909739614486 18909352329494 [--apply]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, orders: list, dry_run: bool) -> None:
|
||||||
|
cfg = cargar_config_tienda(store_config_path)
|
||||||
|
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"])
|
||||||
|
|
||||||
|
skus = {}
|
||||||
|
for order_id in orders:
|
||||||
|
for li in shopify.obtener_orden(order_id).get("line_items", []):
|
||||||
|
sku = (li.get("sku") or "").strip()
|
||||||
|
if sku:
|
||||||
|
skus.setdefault(sku, li.get("name"))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"Comments": f"Stock inicial pruebas ({cfg['store_code']})",
|
||||||
|
"DocumentLines": [
|
||||||
|
{"ItemCode": sku, "WarehouseCode": warehouse, "Quantity": quantity, "UnitPrice": price, "AccountCode": account}
|
||||||
|
for sku in skus
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"{cfg['store_code']}: {len(skus)} SKUs de {len(orders)} órdenes | almacén={warehouse} precio={price} cuenta={account} cantidad={quantity}")
|
||||||
|
if dry_run:
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = sap.forward("POST", "InventoryGenEntries", json_data=payload).json()
|
||||||
|
print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
print(f"ERROR creando la entrada de mercancía: {exc.detail}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("store_config")
|
||||||
|
parser.add_argument("--warehouse", required=True)
|
||||||
|
parser.add_argument("--price", type=float, required=True)
|
||||||
|
parser.add_argument("--account", required=True)
|
||||||
|
parser.add_argument("--quantity", type=float, required=True)
|
||||||
|
parser.add_argument("--orders", type=int, nargs="+", required=True)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
main(args.store_config, args.warehouse, args.price, args.account, args.quantity, args.orders, dry_run=not args.apply)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Integra órdenes puntuales de una tienda, de inicio a fin (Invoice -> Payment
|
||||||
|
-> ITR -> StockTransfer -> Delivery), sin pasar por el ciclo del scheduler.
|
||||||
|
|
||||||
|
Por defecto es DRY-RUN: valida cada orden y muestra el payload de la Invoice
|
||||||
|
y del Payment (este último armado sobre un total equivalente) sin escribir en
|
||||||
|
SAP ni en la base de datos. Con --apply inserta la orden en `orders` y corre
|
||||||
|
el pipeline real para ese id (crea documentos en SAP).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python -m tools.integrar_ordenes <store_code> <shopify_order_id> [...] # dry-run
|
||||||
|
python -m tools.integrar_ordenes <store_code> <shopify_order_id> [...] --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.db import get_order_sys_id, insert_order_header, db_session
|
||||||
|
from core.logger_manager import order_context
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
from domain.business_partner import buscar_rut_en_order
|
||||||
|
from domain.inventory_transfer import resolver_location
|
||||||
|
from domain.invoice import construir_invoice
|
||||||
|
from domain.payment import PaymentConfigError, construir_incoming_payment
|
||||||
|
from domain.rut import formatear_rut
|
||||||
|
from pipeline.runner import ESTADOS_FINANCIEROS_FACTURABLES, analizar_y_procesar_orden
|
||||||
|
|
||||||
|
STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores"
|
||||||
|
|
||||||
|
|
||||||
|
def _dry_run(cfg: dict, shopify: ShopifyClient, order_id: int) -> None:
|
||||||
|
detalle = shopify.obtener_orden(order_id)
|
||||||
|
print(f"{detalle.get('name')} | financial={detalle.get('financial_status')} fulfillment={detalle.get('fulfillment_status')}")
|
||||||
|
|
||||||
|
if detalle.get("cancelled_at"):
|
||||||
|
print(f"-> cancelled (anulada el {detalle['cancelled_at'][:10]}): no se crearía nada")
|
||||||
|
return
|
||||||
|
if detalle.get("financial_status") not in ESTADOS_FINANCIEROS_FACTURABLES or not detalle.get("fulfillments"):
|
||||||
|
print("-> not_eligible (no está pagada o no tiene fulfillments): no se crearía nada")
|
||||||
|
return
|
||||||
|
|
||||||
|
envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0)
|
||||||
|
if envio > 0:
|
||||||
|
print(f"-> error: costo de envío ({envio}) no soportado")
|
||||||
|
return
|
||||||
|
|
||||||
|
rut = buscar_rut_en_order(detalle)
|
||||||
|
card_code = cfg["sap"]["generic_card_code"] if not rut else "(se resuelve en SAP por RUT)"
|
||||||
|
print(f"RUT: {rut or 'sin RUT en note_attributes'} -> CardCode: {card_code}")
|
||||||
|
|
||||||
|
for f in detalle["fulfillments"]:
|
||||||
|
loc = resolver_location(cfg, f["location_id"])
|
||||||
|
print(f"fulfillment {f['id']} location {f['location_id']} -> {loc}")
|
||||||
|
|
||||||
|
if rut:
|
||||||
|
print("(orden con RUT: la Invoice real depende del BusinessPartner en SAP; se muestra con CardCode genérico)")
|
||||||
|
transacciones = shopify.obtener_transacciones(order_id)
|
||||||
|
payload = construir_invoice(
|
||||||
|
detalle, cfg, cfg["sap"]["generic_card_code"], federal_tax_id=formatear_rut(rut) if rut else None, transactions=transacciones
|
||||||
|
)
|
||||||
|
print("--- Invoice ---")
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
total = sum(l["Quantity"] * l["PriceAfterVAT"] for l in payload["DocumentLines"])
|
||||||
|
invoice_sim = {"CardCode": payload["CardCode"], "CardName": payload.get("CardName", ""), "DocDate": payload["DocDate"]}
|
||||||
|
try:
|
||||||
|
pago = construir_incoming_payment(detalle, transacciones, cfg, invoice_sim, 0, total)
|
||||||
|
print("--- IncomingPayment (DocEntry de la Invoice = 0 en dry-run) ---")
|
||||||
|
print(json.dumps(pago, indent=2, ensure_ascii=False))
|
||||||
|
except PaymentConfigError as exc:
|
||||||
|
print(f"--- IncomingPayment: ERROR, no se crearía el pago: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(cfg: dict, shopify: ShopifyClient, order_id: int) -> None:
|
||||||
|
store_code = cfg["store_code"]
|
||||||
|
orden = shopify.obtener_orden(order_id)
|
||||||
|
insert_order_header(
|
||||||
|
store_code,
|
||||||
|
{
|
||||||
|
"shopify_order_id": orden["id"],
|
||||||
|
"order_number": orden.get("name"),
|
||||||
|
"tags": orden.get("tags"),
|
||||||
|
"shopify_created_at": orden.get("created_at"),
|
||||||
|
"financial_status": orden.get("financial_status"),
|
||||||
|
"fulfillment_status": orden.get("fulfillment_status"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sys_id = get_order_sys_id(store_code, order_id)
|
||||||
|
with db_session() as conn:
|
||||||
|
row = dict(conn.execute("SELECT * FROM orders WHERE sys_id = ?", (sys_id,)).fetchone())
|
||||||
|
with order_context(store_code, order_id):
|
||||||
|
ok = analizar_y_procesar_orden(cfg, row)
|
||||||
|
with db_session() as conn:
|
||||||
|
estado = conn.execute("SELECT status, last_error FROM orders WHERE sys_id = ?", (sys_id,)).fetchone()
|
||||||
|
docs = conn.execute(
|
||||||
|
"SELECT doc_type, status, sap_doc_num, error_message FROM order_documents WHERE order_sys_id = ? ORDER BY sequence",
|
||||||
|
(sys_id,),
|
||||||
|
).fetchall()
|
||||||
|
print(f"resultado={ok} status={estado['status']} last_error={estado['last_error']}")
|
||||||
|
for d in docs:
|
||||||
|
print(f" {d['doc_type']}: {d['status']} DocNum={d['sap_doc_num']} {d['error_message'] or ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
apply = "--apply" in args
|
||||||
|
args = [a for a in args if a != "--apply"]
|
||||||
|
store_code, ids = args[0], [int(x) for x in args[1:]]
|
||||||
|
|
||||||
|
cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json")
|
||||||
|
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||||
|
|
||||||
|
print(f"MODO: {'APPLY (escribe en SAP)' if apply else 'DRY-RUN (no escribe nada)'} | tienda {store_code} | {len(ids)} órdenes")
|
||||||
|
for order_id in ids:
|
||||||
|
print(f"\n===== Orden {order_id} =====")
|
||||||
|
(_apply if apply else (lambda c, s, o: _dry_run(c, s, o)))(cfg, shopify, order_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Dry-run del IncomingPayment: para una o más órdenes de una tienda descarga
|
||||||
|
detalle y transacciones de Shopify (GET), toma la Invoice de SAP si la orden
|
||||||
|
ya está integrada (GET) o una sintética si no, y muestra el JSON de pago que
|
||||||
|
se enviaría. NO escribe en SAP ni en la base de datos.
|
||||||
|
|
||||||
|
Uso: python -m tools.probar_pago <store_code> <shopify_order_id> [...]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.db import db_session
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from core.shopify_client import ShopifyClient
|
||||||
|
from domain.payment import PaymentConfigError, construir_incoming_payment, transacciones_efectivas
|
||||||
|
|
||||||
|
STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores"
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice(sap: SAPServiceLayerClient, store_code: str, order_id: int, detalle: dict) -> tuple[dict, str]:
|
||||||
|
with db_session() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.sap_doc_entry FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id
|
||||||
|
WHERE o.store_code = ? AND o.shopify_order_id = ? AND d.doc_type = 'INVOICE' AND d.status = 'created'
|
||||||
|
""",
|
||||||
|
(store_code, order_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return sap.forward("GET", f"Invoices({row['sap_doc_entry']})").json(), f"Invoice real DocEntry {row['sap_doc_entry']}"
|
||||||
|
|
||||||
|
sintetica = {
|
||||||
|
"DocEntry": 0,
|
||||||
|
"CardCode": "C99998",
|
||||||
|
"CardName": "(Invoice sintética)",
|
||||||
|
"DocDate": detalle["created_at"][:10],
|
||||||
|
"DocumentLines": [{"Quantity": 1, "PriceAfterVAT": float(detalle["total_price"])}],
|
||||||
|
}
|
||||||
|
return sintetica, "Invoice sintética (orden aún sin Invoice en SAP)"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
store_code, ids = sys.argv[1], [int(x) for x in sys.argv[2:]]
|
||||||
|
cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json")
|
||||||
|
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"])
|
||||||
|
|
||||||
|
for order_id in ids:
|
||||||
|
print(f"\n===== Orden {order_id} =====")
|
||||||
|
detalle = shopify.obtener_orden(order_id)
|
||||||
|
transacciones = shopify.obtener_transacciones(order_id)
|
||||||
|
invoice, origen = _invoice(sap, store_code, order_id, detalle)
|
||||||
|
total = sum(Decimal(str(l["Quantity"])) * Decimal(str(l["PriceAfterVAT"])) for l in invoice["DocumentLines"])
|
||||||
|
|
||||||
|
print(f"{detalle.get('name')} | payment_gateway_names={detalle.get('payment_gateway_names')} | total_price={detalle.get('total_price')}")
|
||||||
|
print(f"{len(transacciones)} transacciones -> {len(transacciones_efectivas(transacciones))} efectivas | {origen} | total Invoice={total}")
|
||||||
|
try:
|
||||||
|
payload = construir_incoming_payment(detalle, transacciones, cfg, invoice, invoice["DocEntry"], float(total))
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
except PaymentConfigError as exc:
|
||||||
|
print(f"ERROR (no se crearía el pago): {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
POST {{SAP_URL}}/Login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"CompanyDB": "{{SAP_COMPANY_DB}}",
|
||||||
|
"UserName": "{{SAP_USERNAME}}",
|
||||||
|
"Password": "{{SAP_PASSWORD}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
POST {{SAP_URL}}/Login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"CompanyDB": "{{SAP_COMPANY_DB}}",
|
||||||
|
"UserName": "{{SAP_USERNAME}}",
|
||||||
|
"Password": "{{SAP_PASSWORD}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
GET {{SAP_URL}}/Invoices(334)
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
GET {{SAP_URL}}/IncomingPayments(47)
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
GET {{SAP_URL}}/BusinessPartners('C70000')
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
GET {{SAP_URL}}/IncomingPayments(89)
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerOrdenes
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerOrdenesFields
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&since_id=7425850048790&limit=50
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerOrden
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7554120581398.json
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerTransactions
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7429185339670/transactions.json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerLocations
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/locations.json
|
||||||
|
|
||||||
|
# 90870743318
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerOrdenesPorRangoFecha
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&created_at_min=2026-08-01T00:00:00-04:00&created_at_max=2026-08-02T23:59:59-04:00&limit=50
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerCliente
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270.json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @name obtenerClienteMetafields
|
||||||
|
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270/metafields.json
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Utilidad puntual (no forma parte del pipeline recurrente): crea un
|
||||||
|
StockTransfer directo entre dos almacenes para todos los SKU del grupo de
|
||||||
|
artículos indicado.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
.venv/bin/python tools/transferir_stock.py config/stores/snobbycl.json \
|
||||||
|
--from 8801 --to 7701 --quantity 500
|
||||||
|
|
||||||
|
Agregar --apply para ejecutar el POST real (por defecto es dry-run).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from core.config import cargar_config_tienda
|
||||||
|
from core.errors import SAPServiceLayerError
|
||||||
|
from core.sap import SAPServiceLayerClient
|
||||||
|
from tools.cargar_entrada_mercancia import obtener_skus_grupo, ITEMS_GROUP_CODE
|
||||||
|
|
||||||
|
|
||||||
|
def main(store_config_path: str, from_wh: str, to_wh: str, quantity: float, dry_run: bool) -> None:
|
||||||
|
store_config = cargar_config_tienda(store_config_path)
|
||||||
|
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||||
|
|
||||||
|
skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE)
|
||||||
|
print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}")
|
||||||
|
|
||||||
|
document_lines = [
|
||||||
|
{
|
||||||
|
"ItemCode": sku,
|
||||||
|
"Quantity": quantity,
|
||||||
|
"FromWarehouseCode": from_wh,
|
||||||
|
"WarehouseCode": to_wh,
|
||||||
|
}
|
||||||
|
for sku in skus
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = {"StockTransferLines": document_lines}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(json.dumps(payload["StockTransferLines"][:3], indent=2, ensure_ascii=False))
|
||||||
|
print(f"... ({len(document_lines)} líneas en total, de {from_wh} a {to_wh}, cantidad={quantity})")
|
||||||
|
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = sap.forward("POST", "StockTransfers", json_data=payload)
|
||||||
|
data = resp.json()
|
||||||
|
print(f"StockTransfer creado. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||||
|
except SAPServiceLayerError as exc:
|
||||||
|
print(f"ERROR creando el StockTransfer: {exc.detail}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("store_config")
|
||||||
|
parser.add_argument("--from", dest="from_wh", required=True)
|
||||||
|
parser.add_argument("--to", dest="to_wh", required=True)
|
||||||
|
parser.add_argument("--quantity", type=float, required=True)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
main(args.store_config, args.from_wh, args.to_wh, args.quantity, dry_run=not args.apply)
|
||||||
Reference in new issue
Block a user