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:
georgeandClaude Sonnet 5.5 committed 2026-09-30 19:22:40 -05:00
commit 901dd8763d
47 files changed
+4709

No files matched your search

+10
View File
@@ -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
View File
@@ -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
+52
View File
@@ -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 |
+54
View File
@@ -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
}
+97
View File
@@ -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
}
+52
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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}")
+21
View File
@@ -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
+183
View File
@@ -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))
+80
View File
@@ -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 -&gt; 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
View File
@@ -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
+114
View File
@@ -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", [])
View File
Whitespace-only changes.
+84
View File
@@ -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.
+103
View File
@@ -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 |
+198
View File
@@ -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.
+169
View File
@@ -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.
+281
View File
@@ -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)`.
+235
View File
@@ -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` |
+87
View File
@@ -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`.
+117
View File
@@ -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}
+38
View File
@@ -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,
}
+86
View File
@@ -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
+61
View File
@@ -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,
}
+222
View File
@@ -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})
+29
View File
@@ -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)]
+136
View File
@@ -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
+47
View File
@@ -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]}"
+38
View File
@@ -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,
}
View File
Whitespace-only changes.
+144
View File
@@ -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()
+363
View File
@@ -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
+5
View File
@@ -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
+9
View File
@@ -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]
}
+10
View File
@@ -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=
+82
View File
@@ -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)
+108
View File
@@ -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)
+200
View File
@@ -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()
+78
View File
@@ -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])
+65
View File
@@ -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)
+122
View File
@@ -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()
+68
View File
@@ -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()
+46
View File
@@ -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
+42
View File
@@ -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
+67
View File
@@ -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)