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 @@
# 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)