Integración Shopify → SAP Business One (snobbycl y on-chile)
Pipeline de integración de órdenes de Shopify a SAP Service Layer:
Invoice de reserva, pago con tarjeta o transferencia por transacción,
solicitud de traslado, traslado de stock y entrega.
- Invoice igual a lo cobrado; fulfillments limitados a lo facturado
- Estados terminales integrated, timeout, ignored y cancelled
- Idempotencia contra SAP: adopta documentos existentes en vez de duplicar
- Reintentos de red en el cliente de Shopify
- Secretos fuera de los JSON de tienda (${VARIABLE} resuelta desde .env)
- Herramientas de consulta, integración puntual y carga de pruebas
- Documentación en docs/ (flujo, casos especiales, mapeo, configuración)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
901dd8763d
47 files changed
+4709
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Variables para las peticiones de REST Client (tools/*.http). Copiar a tools/.env y completar.
|
||||
SHOPIFY_API_ID=
|
||||
SHOPIFY_STORE=
|
||||
SHOPIFY_API_VERSION=
|
||||
SHOPIFY_ACCESS_TOKEN=
|
||||
# SAP (tools/sap.http), si se usa:
|
||||
# SAP_URL=
|
||||
# SAP_COMPANYDB=
|
||||
# SAP_USER=
|
||||
# SAP_PASSWORD=
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Utilidad puntual (no forma parte del pipeline recurrente): crea una Entrada
|
||||
de Mercancía (InventoryGenEntries) en SAP con todos los SKU del grupo de
|
||||
artículos indicado, para dejar el stock inicial en un almacén.
|
||||
|
||||
Uso:
|
||||
.venv/bin/python tools/cargar_entrada_mercancia.py config/stores/snobbycl.json \
|
||||
--warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 1000
|
||||
|
||||
Agregar --apply para ejecutar el POST real (por defecto es dry-run).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
|
||||
ITEMS_GROUP_CODE = 108
|
||||
|
||||
|
||||
def obtener_skus_grupo(sap: SAPServiceLayerClient, group_code: int) -> List[str]:
|
||||
resp = sap.forward(
|
||||
"GET",
|
||||
"Items",
|
||||
params={"$select": "ItemCode", "$filter": f"ItemsGroupCode eq {group_code}"},
|
||||
extra_headers={"B1S-PageSize": "1000"},
|
||||
)
|
||||
data = resp.json()
|
||||
return [i["ItemCode"] for i in data.get("value", [])]
|
||||
|
||||
|
||||
def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, dry_run: bool) -> None:
|
||||
store_config = cargar_config_tienda(store_config_path)
|
||||
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||
|
||||
skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE)
|
||||
print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}")
|
||||
|
||||
document_lines = [
|
||||
{
|
||||
"ItemCode": sku,
|
||||
"WarehouseCode": warehouse,
|
||||
"Quantity": quantity,
|
||||
"UnitPrice": price,
|
||||
"AccountCode": account,
|
||||
}
|
||||
for sku in skus
|
||||
]
|
||||
|
||||
payload = {"DocumentLines": document_lines}
|
||||
|
||||
if dry_run:
|
||||
print(json.dumps(payload["DocumentLines"][:3], indent=2, ensure_ascii=False))
|
||||
print(f"... ({len(document_lines)} líneas en total, almacén={warehouse}, precio={price}, cuenta={account}, cantidad={quantity})")
|
||||
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = sap.forward("POST", "InventoryGenEntries", json_data=payload)
|
||||
data = resp.json()
|
||||
print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||
except SAPServiceLayerError as exc:
|
||||
print(f"ERROR creando la entrada de mercancía: {exc.detail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("store_config")
|
||||
parser.add_argument("--warehouse", required=True)
|
||||
parser.add_argument("--price", type=float, required=True)
|
||||
parser.add_argument("--account", required=True)
|
||||
parser.add_argument("--quantity", type=float, required=True)
|
||||
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||
args = parser.parse_args()
|
||||
main(args.store_config, args.warehouse, args.price, args.account, args.quantity, dry_run=not args.apply)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Utilidad puntual (no forma parte del pipeline recurrente): recorre las
|
||||
órdenes ya descargadas en SQLite para una tienda, trae el detalle completo
|
||||
de cada una desde Shopify, extrae SKU + nombre de line_items, deduplica por
|
||||
SKU, y crea en SAP los Items faltantes (grupo 108, artículo de venta +
|
||||
inventario).
|
||||
|
||||
Uso:
|
||||
.venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json
|
||||
.venv/bin/python tools/cargar_items_faltantes.py config/stores/snobbycl.json --apply
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.db import db_session
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from core.shopify_client import ShopifyClient
|
||||
from domain.items import item_existe
|
||||
|
||||
ITEMS_GROUP_CODE = 108
|
||||
|
||||
|
||||
def obtener_shopify_order_ids(store_code: str) -> List[int]:
|
||||
with db_session() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT shopify_order_id FROM orders WHERE store_code = ? ORDER BY shopify_order_id",
|
||||
(store_code,),
|
||||
).fetchall()
|
||||
return [r["shopify_order_id"] for r in rows]
|
||||
|
||||
|
||||
def extraer_skus(shopify: ShopifyClient, order_ids: List[int]) -> Dict[str, str]:
|
||||
"""Devuelve {sku: nombre} deduplicado, recorriendo el detalle de cada orden."""
|
||||
skus: Dict[str, str] = {}
|
||||
for idx, order_id in enumerate(order_ids, start=1):
|
||||
detalle = shopify.obtener_orden(order_id)
|
||||
for item in detalle.get("line_items", []):
|
||||
sku = (item.get("sku") or "").strip()
|
||||
nombre = item.get("name") or item.get("title") or sku
|
||||
if sku and sku not in skus:
|
||||
skus[sku] = nombre
|
||||
print(f" [{idx}/{len(order_ids)}] orden {order_id}: {len(detalle.get('line_items', []))} line_items")
|
||||
return skus
|
||||
|
||||
|
||||
def crear_item(sap: SAPServiceLayerClient, sku: str, nombre: str) -> None:
|
||||
payload = {
|
||||
"ItemCode": sku,
|
||||
"ItemName": nombre[:100],
|
||||
"ItemsGroupCode": ITEMS_GROUP_CODE,
|
||||
"SalesItem": "tYES",
|
||||
"InventoryItem": "tYES",
|
||||
"PurchaseItem": "tNO",
|
||||
}
|
||||
sap.forward("POST", "Items", json_data=payload)
|
||||
|
||||
|
||||
def main(store_config_path: str, dry_run: bool) -> None:
|
||||
store_config = cargar_config_tienda(store_config_path)
|
||||
store_code = store_config["store_code"]
|
||||
|
||||
shopify = ShopifyClient.from_store_config(store_config["shopify"])
|
||||
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||
|
||||
order_ids = obtener_shopify_order_ids(store_code)
|
||||
print(f"Órdenes a revisar: {len(order_ids)}")
|
||||
|
||||
skus = extraer_skus(shopify, order_ids)
|
||||
print(f"\nSKUs únicos encontrados: {len(skus)}\n")
|
||||
|
||||
existentes, creados, errores = 0, 0, 0
|
||||
for sku, nombre in skus.items():
|
||||
if item_existe(sap, sku):
|
||||
existentes += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Crearía: {sku!r} - {nombre!r}")
|
||||
continue
|
||||
|
||||
try:
|
||||
crear_item(sap, sku, nombre)
|
||||
creados += 1
|
||||
print(f"Creado: {sku} - {nombre}")
|
||||
except SAPServiceLayerError as exc:
|
||||
errores += 1
|
||||
print(f"ERROR creando {sku}: {exc.detail}")
|
||||
|
||||
faltantes = len(skus) - existentes
|
||||
print(f"\nResumen: ya existían={existentes} | faltantes detectados={faltantes} | creados={creados} | errores={errores}")
|
||||
if dry_run:
|
||||
print("(dry-run: no se escribió nada en SAP, corré con --apply para crear los items)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("store_config", help="Ruta al JSON de configuración de la tienda")
|
||||
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||
args = parser.parse_args()
|
||||
main(args.store_config, dry_run=not args.apply)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Consultas de solo lectura sobre data/shopify_sap.db (y logs/events.jsonl).
|
||||
|
||||
Uso (desde la raíz del proyecto):
|
||||
.venv/bin/python -m tools.consultar resumen
|
||||
.venv/bin/python -m tools.consultar descargadas [--store snobbycl] [--limit 50]
|
||||
.venv/bin/python -m tools.consultar integradas [--store snobbycl] [--limit 50]
|
||||
.venv/bin/python -m tools.consultar errores [--store snobbycl] [--limit 50]
|
||||
.venv/bin/python -m tools.consultar orden --name "#8064" [--store snobbycl]
|
||||
|
||||
- resumen: órdenes por tienda y estado, con barras.
|
||||
- descargadas: últimas órdenes descargadas (por fecha de descarga).
|
||||
- integradas: últimas órdenes integradas, con los documentos SAP creados.
|
||||
- errores: órdenes en error, documentos con error y eventos de error del log.
|
||||
|
||||
Las horas de la BD (UTC) se muestran en hora local (APP_TIMEZONE o la del sistema); las del log de eventos ya vienen en hora local.
|
||||
- orden: cabecera, fulfillments y todos los pasos (documentos) de una orden.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Sequence
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.config import cargar_env
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = ROOT / "data" / "shopify_sap.db"
|
||||
EVENTS_PATH = ROOT / "logs" / "events.jsonl"
|
||||
|
||||
|
||||
def _zona():
|
||||
cargar_env()
|
||||
nombre = os.getenv("APP_TIMEZONE")
|
||||
return ZoneInfo(nombre) if nombre else None # None = zona horaria del sistema
|
||||
|
||||
|
||||
def hora_local(valor: Any) -> Any:
|
||||
"""
|
||||
La BD guarda time_insert/time_update en UTC (strftime 'now'); se muestran en hora local.
|
||||
Zona: variable APP_TIMEZONE (ej. America/Santiago) si está definida; si no, la del sistema.
|
||||
"""
|
||||
if not valor:
|
||||
return valor
|
||||
try:
|
||||
dt = datetime.strptime(str(valor), "%Y-%m-%d %H:%M:%S.%f" if "." in str(valor) else "%Y-%m-%d %H:%M:%S")
|
||||
return dt.replace(tzinfo=timezone.utc).astimezone(_zona()).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return valor
|
||||
|
||||
|
||||
def conectar() -> sqlite3.Connection:
|
||||
# Solo lectura: no bloquea ni modifica la BD aunque el scheduler esté corriendo.
|
||||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def tabla(filas: Sequence[Sequence[Any]], columnas: List[str], max_ancho: int = 60) -> None:
|
||||
if not filas:
|
||||
print("(sin resultados)")
|
||||
return
|
||||
celdas = [[("" if v is None else str(v))[:max_ancho] for v in fila] for fila in filas]
|
||||
anchos = [max(len(c), *(len(f[i]) for f in celdas)) for i, c in enumerate(columnas)]
|
||||
linea = " ".join(c.ljust(anchos[i]) for i, c in enumerate(columnas))
|
||||
print(linea)
|
||||
print(" ".join("-" * a for a in anchos))
|
||||
for f in celdas:
|
||||
print(" ".join(v.ljust(anchos[i]) for i, v in enumerate(f)))
|
||||
print(f"\n{len(filas)} fila(s)")
|
||||
|
||||
|
||||
def filtro_tienda(store: str | None, alias: str = "o") -> tuple[str, list]:
|
||||
return (f"AND {alias}.store_code = ?", [store]) if store else ("", [])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def resumen(conn: sqlite3.Connection, args) -> None:
|
||||
filas = conn.execute(
|
||||
"SELECT store_code, status, COUNT(*) n FROM orders GROUP BY store_code, status ORDER BY store_code, n DESC"
|
||||
).fetchall()
|
||||
maximo = max((r["n"] for r in filas), default=1)
|
||||
salida = [(r["store_code"], r["status"], r["n"], "█" * max(1, round(r["n"] / maximo * 40))) for r in filas]
|
||||
tabla(salida, ["tienda", "estado", "órdenes", ""])
|
||||
|
||||
|
||||
def descargadas(conn: sqlite3.Connection, args) -> None:
|
||||
extra, params = filtro_tienda(args.store)
|
||||
filas = conn.execute(
|
||||
f"""
|
||||
SELECT o.time_insert, o.store_code, o.order_number, o.shopify_order_id, o.financial_status,
|
||||
o.fulfillment_status, o.status
|
||||
FROM orders o WHERE 1=1 {extra}
|
||||
ORDER BY o.time_insert DESC, o.sys_id DESC LIMIT ?
|
||||
""",
|
||||
params + [args.limit],
|
||||
).fetchall()
|
||||
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["descargada (local)", "tienda", "orden", "shopify_id", "financiero", "fulfillment", "estado"])
|
||||
|
||||
|
||||
def integradas(conn: sqlite3.Connection, args) -> None:
|
||||
extra, params = filtro_tienda(args.store)
|
||||
filas = conn.execute(
|
||||
f"""
|
||||
SELECT o.time_update, o.store_code, o.order_number, o.card_code,
|
||||
(SELECT group_concat(d.doc_type || ' ' || d.sap_doc_num, ' | ')
|
||||
FROM order_documents d WHERE d.order_sys_id = o.sys_id AND d.status = 'created') docs
|
||||
FROM orders o WHERE o.status = 'integrated' {extra}
|
||||
ORDER BY o.time_update DESC LIMIT ?
|
||||
""",
|
||||
params + [args.limit],
|
||||
).fetchall()
|
||||
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["integrada (local)", "tienda", "orden", "cliente", "documentos SAP"], max_ancho=110)
|
||||
|
||||
|
||||
def errores(conn: sqlite3.Connection, args) -> None:
|
||||
extra, params = filtro_tienda(args.store)
|
||||
print("== Órdenes en estado error ==")
|
||||
filas = conn.execute(
|
||||
f"SELECT time_update, store_code, order_number, last_error FROM orders o WHERE status = 'error' {extra} ORDER BY time_update DESC LIMIT ?",
|
||||
params + [args.limit],
|
||||
).fetchall()
|
||||
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizada (local)", "tienda", "orden", "último error"], max_ancho=110)
|
||||
|
||||
print("\n== Documentos con error ==")
|
||||
filas = conn.execute(
|
||||
f"""
|
||||
SELECT d.time_update, o.store_code, o.order_number, d.doc_type, d.attempts, d.error_message
|
||||
FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id
|
||||
WHERE d.status = 'error' {extra} ORDER BY d.time_update DESC LIMIT ?
|
||||
""",
|
||||
params + [args.limit],
|
||||
).fetchall()
|
||||
tabla([(hora_local(r[0]),) + tuple(r)[1:] for r in filas], ["actualizado (local)", "tienda", "orden", "documento", "intentos", "error"], max_ancho=90)
|
||||
|
||||
print("\n== Eventos de error recientes (logs/events.jsonl) ==")
|
||||
eventos = []
|
||||
if EVENTS_PATH.exists():
|
||||
for linea in EVENTS_PATH.read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
e = json.loads(linea)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if e.get("level") in ("ERROR", "CRITICAL") and (not args.store or e.get("store_code") == args.store):
|
||||
eventos.append((e.get("timestamp"), e.get("store_code"), e.get("shopify_order_id"), e.get("event"), e.get("message")))
|
||||
tabla(eventos[-args.limit:][::-1], ["cuándo", "tienda", "shopify_id", "evento", "mensaje"], max_ancho=90)
|
||||
|
||||
|
||||
def orden(conn: sqlite3.Connection, args) -> None:
|
||||
extra, params = filtro_tienda(args.store)
|
||||
nombre = args.name if args.name.startswith("#") else f"#{args.name}"
|
||||
ordenes = conn.execute(f"SELECT * FROM orders o WHERE o.order_number = ? {extra}", [nombre] + params).fetchall()
|
||||
if not ordenes:
|
||||
print(f"No hay órdenes con name {nombre}")
|
||||
return
|
||||
|
||||
for o in ordenes:
|
||||
print(f"\n===== {o['order_number']} | {o['store_code']} | shopify_id {o['shopify_order_id']} =====")
|
||||
for campo in ("status", "last_error", "financial_status", "fulfillment_status", "payment_gateway_names",
|
||||
"card_code", "tags", "shopify_created_at", "time_insert", "time_update"):
|
||||
print(f" {campo:<22} {hora_local(o[campo]) if campo in ('time_insert', 'time_update') else o[campo]}")
|
||||
|
||||
print("\n Fulfillments")
|
||||
fulfillments = conn.execute("SELECT * FROM order_fulfillments WHERE order_sys_id = ?", (o["sys_id"],)).fetchall()
|
||||
tabla([(f["sys_id"], f["shopify_fulfillment_id"], f["location_id"], "tienda" if f["is_store"] else "CD", f["warehouse_code"])
|
||||
for f in fulfillments], ["id", "shopify_fulfillment", "location", "tipo", "bodega"])
|
||||
|
||||
print("\n Pasos (documentos SAP)")
|
||||
docs = conn.execute(
|
||||
"SELECT * FROM order_documents WHERE order_sys_id = ? ORDER BY sequence, fulfillment_sys_id, sys_id", (o["sys_id"],)
|
||||
).fetchall()
|
||||
tabla([(d["sequence"], d["doc_type"], d["fulfillment_sys_id"], d["status"], d["sap_doc_entry"], d["sap_doc_num"],
|
||||
d["attempts"], hora_local(d["time_update"]), d["error_message"]) for d in docs],
|
||||
["seq", "documento", "fulfillment", "estado", "DocEntry", "DocNum", "intentos", "actualizado (local)", "error"], max_ancho=70)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Consultas de solo lectura sobre la BD de Shopify → SAP")
|
||||
sub = parser.add_subparsers(dest="comando", required=True)
|
||||
sub.add_parser("resumen")
|
||||
for nombre in ("descargadas", "integradas", "errores"):
|
||||
p = sub.add_parser(nombre)
|
||||
p.add_argument("--store")
|
||||
p.add_argument("--limit", type=int, default=50)
|
||||
p = sub.add_parser("orden")
|
||||
p.add_argument("--name", required=True)
|
||||
p.add_argument("--store")
|
||||
|
||||
args = parser.parse_args()
|
||||
with conectar() as conn:
|
||||
{"resumen": resumen, "descargadas": descargadas, "integradas": integradas, "errores": errores, "orden": orden}[args.comando](conn, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Descarga a download/ el JSON de Shopify (orden + transacciones) de los casos
|
||||
especiales de una tienda, para analizarlos aparte. Solo lectura sobre Shopify
|
||||
y la BD; solo escribe archivos locales en download/.
|
||||
|
||||
Se consideran especiales las órdenes de la BD que no están integradas (pending,
|
||||
error, not_eligible, in_progress) y que, al traer el detalle, cumplen alguno de:
|
||||
- tienen refunds
|
||||
- financial_status distinto de paid
|
||||
- no tienen fulfillments
|
||||
- tienen costo de envío > 0
|
||||
- están en status error en la BD
|
||||
|
||||
Uso:
|
||||
.venv/bin/python -m tools.descargar_ordenes_especiales <store_code>
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.db import db_session
|
||||
from core.shopify_client import ShopifyClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOWNLOAD_DIR = ROOT / "download"
|
||||
|
||||
|
||||
def motivos(detalle: dict, status_bd: str) -> list[str]:
|
||||
razones = []
|
||||
if detalle.get("refunds"):
|
||||
razones.append("reembolsos")
|
||||
if detalle.get("financial_status") != "paid":
|
||||
razones.append(f"financial_status={detalle.get('financial_status')}")
|
||||
if not detalle.get("fulfillments"):
|
||||
razones.append("sin fulfillments")
|
||||
envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0)
|
||||
if envio > 0:
|
||||
razones.append(f"envío={envio:g}")
|
||||
if status_bd == "error":
|
||||
razones.append("status error en BD")
|
||||
return razones
|
||||
|
||||
|
||||
def main(store_code: str) -> None:
|
||||
cfg = cargar_config_tienda(ROOT / "config" / "stores" / f"{store_code}.json")
|
||||
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||
|
||||
with db_session() as conn:
|
||||
filas = conn.execute(
|
||||
"SELECT shopify_order_id, order_number, status FROM orders WHERE store_code = ? AND status != 'integrated' "
|
||||
"AND status != 'timeout' ORDER BY shopify_order_id",
|
||||
(store_code,),
|
||||
).fetchall()
|
||||
|
||||
DOWNLOAD_DIR.mkdir(exist_ok=True)
|
||||
guardadas = []
|
||||
for f in filas:
|
||||
detalle = shopify.obtener_orden(f["shopify_order_id"])
|
||||
razones = motivos(detalle, f["status"])
|
||||
if not razones:
|
||||
continue
|
||||
nombre = detalle.get("name")
|
||||
transacciones = shopify.obtener_transacciones(f["shopify_order_id"])
|
||||
json.dump({"order": detalle}, open(DOWNLOAD_DIR / f"shopify_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
|
||||
json.dump({"transactions": transacciones}, open(DOWNLOAD_DIR / f"shopify_transactions_{nombre}.json", "w", encoding="utf-8"), indent=2, ensure_ascii=False)
|
||||
guardadas.append((nombre, f["status"], "; ".join(razones)))
|
||||
|
||||
print(f"{store_code}: {len(filas)} órdenes revisadas, {len(guardadas)} casos especiales guardados en download/")
|
||||
for g in guardadas:
|
||||
print(f" {g[0]:<8} BD={g[1]:<12} {g[2]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.disable(logging.CRITICAL)
|
||||
main(sys.argv[1])
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Entrada de Mercancía (InventoryGenEntries) solo con los SKUs de órdenes
|
||||
puntuales de Shopify (no todo el grupo de artículos). DRY-RUN por defecto.
|
||||
|
||||
Uso:
|
||||
.venv/bin/python -m tools.entrada_mercancia_ordenes config/stores/snobbycl.json \
|
||||
--warehouse 8801 --price 5000 --account 1-1-080-40-000 --quantity 2 \
|
||||
--orders 18909739614486 18909352329494 [--apply]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from core.shopify_client import ShopifyClient
|
||||
|
||||
|
||||
def main(store_config_path: str, warehouse: str, price: float, account: str, quantity: float, orders: list, dry_run: bool) -> None:
|
||||
cfg = cargar_config_tienda(store_config_path)
|
||||
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||
sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"])
|
||||
|
||||
skus = {}
|
||||
for order_id in orders:
|
||||
for li in shopify.obtener_orden(order_id).get("line_items", []):
|
||||
sku = (li.get("sku") or "").strip()
|
||||
if sku:
|
||||
skus.setdefault(sku, li.get("name"))
|
||||
|
||||
payload = {
|
||||
"Comments": f"Stock inicial pruebas ({cfg['store_code']})",
|
||||
"DocumentLines": [
|
||||
{"ItemCode": sku, "WarehouseCode": warehouse, "Quantity": quantity, "UnitPrice": price, "AccountCode": account}
|
||||
for sku in skus
|
||||
],
|
||||
}
|
||||
|
||||
print(f"{cfg['store_code']}: {len(skus)} SKUs de {len(orders)} órdenes | almacén={warehouse} precio={price} cuenta={account} cantidad={quantity}")
|
||||
if dry_run:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||
return
|
||||
|
||||
try:
|
||||
data = sap.forward("POST", "InventoryGenEntries", json_data=payload).json()
|
||||
print(f"Entrada de mercancía creada. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||
except SAPServiceLayerError as exc:
|
||||
print(f"ERROR creando la entrada de mercancía: {exc.detail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.disable(logging.CRITICAL)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("store_config")
|
||||
parser.add_argument("--warehouse", required=True)
|
||||
parser.add_argument("--price", type=float, required=True)
|
||||
parser.add_argument("--account", required=True)
|
||||
parser.add_argument("--quantity", type=float, required=True)
|
||||
parser.add_argument("--orders", type=int, nargs="+", required=True)
|
||||
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||
args = parser.parse_args()
|
||||
main(args.store_config, args.warehouse, args.price, args.account, args.quantity, args.orders, dry_run=not args.apply)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Integra órdenes puntuales de una tienda, de inicio a fin (Invoice -> Payment
|
||||
-> ITR -> StockTransfer -> Delivery), sin pasar por el ciclo del scheduler.
|
||||
|
||||
Por defecto es DRY-RUN: valida cada orden y muestra el payload de la Invoice
|
||||
y del Payment (este último armado sobre un total equivalente) sin escribir en
|
||||
SAP ni en la base de datos. Con --apply inserta la orden en `orders` y corre
|
||||
el pipeline real para ese id (crea documentos en SAP).
|
||||
|
||||
Uso:
|
||||
python -m tools.integrar_ordenes <store_code> <shopify_order_id> [...] # dry-run
|
||||
python -m tools.integrar_ordenes <store_code> <shopify_order_id> [...] --apply
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.db import get_order_sys_id, insert_order_header, db_session
|
||||
from core.logger_manager import order_context
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from core.shopify_client import ShopifyClient
|
||||
from domain.business_partner import buscar_rut_en_order
|
||||
from domain.inventory_transfer import resolver_location
|
||||
from domain.invoice import construir_invoice
|
||||
from domain.payment import PaymentConfigError, construir_incoming_payment
|
||||
from domain.rut import formatear_rut
|
||||
from pipeline.runner import ESTADOS_FINANCIEROS_FACTURABLES, analizar_y_procesar_orden
|
||||
|
||||
STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores"
|
||||
|
||||
|
||||
def _dry_run(cfg: dict, shopify: ShopifyClient, order_id: int) -> None:
|
||||
detalle = shopify.obtener_orden(order_id)
|
||||
print(f"{detalle.get('name')} | financial={detalle.get('financial_status')} fulfillment={detalle.get('fulfillment_status')}")
|
||||
|
||||
if detalle.get("cancelled_at"):
|
||||
print(f"-> cancelled (anulada el {detalle['cancelled_at'][:10]}): no se crearía nada")
|
||||
return
|
||||
if detalle.get("financial_status") not in ESTADOS_FINANCIEROS_FACTURABLES or not detalle.get("fulfillments"):
|
||||
print("-> not_eligible (no está pagada o no tiene fulfillments): no se crearía nada")
|
||||
return
|
||||
|
||||
envio = float(((detalle.get("total_shipping_price_set") or {}).get("shop_money") or {}).get("amount", 0) or 0)
|
||||
if envio > 0:
|
||||
print(f"-> error: costo de envío ({envio}) no soportado")
|
||||
return
|
||||
|
||||
rut = buscar_rut_en_order(detalle)
|
||||
card_code = cfg["sap"]["generic_card_code"] if not rut else "(se resuelve en SAP por RUT)"
|
||||
print(f"RUT: {rut or 'sin RUT en note_attributes'} -> CardCode: {card_code}")
|
||||
|
||||
for f in detalle["fulfillments"]:
|
||||
loc = resolver_location(cfg, f["location_id"])
|
||||
print(f"fulfillment {f['id']} location {f['location_id']} -> {loc}")
|
||||
|
||||
if rut:
|
||||
print("(orden con RUT: la Invoice real depende del BusinessPartner en SAP; se muestra con CardCode genérico)")
|
||||
transacciones = shopify.obtener_transacciones(order_id)
|
||||
payload = construir_invoice(
|
||||
detalle, cfg, cfg["sap"]["generic_card_code"], federal_tax_id=formatear_rut(rut) if rut else None, transactions=transacciones
|
||||
)
|
||||
print("--- Invoice ---")
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
total = sum(l["Quantity"] * l["PriceAfterVAT"] for l in payload["DocumentLines"])
|
||||
invoice_sim = {"CardCode": payload["CardCode"], "CardName": payload.get("CardName", ""), "DocDate": payload["DocDate"]}
|
||||
try:
|
||||
pago = construir_incoming_payment(detalle, transacciones, cfg, invoice_sim, 0, total)
|
||||
print("--- IncomingPayment (DocEntry de la Invoice = 0 en dry-run) ---")
|
||||
print(json.dumps(pago, indent=2, ensure_ascii=False))
|
||||
except PaymentConfigError as exc:
|
||||
print(f"--- IncomingPayment: ERROR, no se crearía el pago: {exc}")
|
||||
|
||||
|
||||
def _apply(cfg: dict, shopify: ShopifyClient, order_id: int) -> None:
|
||||
store_code = cfg["store_code"]
|
||||
orden = shopify.obtener_orden(order_id)
|
||||
insert_order_header(
|
||||
store_code,
|
||||
{
|
||||
"shopify_order_id": orden["id"],
|
||||
"order_number": orden.get("name"),
|
||||
"tags": orden.get("tags"),
|
||||
"shopify_created_at": orden.get("created_at"),
|
||||
"financial_status": orden.get("financial_status"),
|
||||
"fulfillment_status": orden.get("fulfillment_status"),
|
||||
},
|
||||
)
|
||||
sys_id = get_order_sys_id(store_code, order_id)
|
||||
with db_session() as conn:
|
||||
row = dict(conn.execute("SELECT * FROM orders WHERE sys_id = ?", (sys_id,)).fetchone())
|
||||
with order_context(store_code, order_id):
|
||||
ok = analizar_y_procesar_orden(cfg, row)
|
||||
with db_session() as conn:
|
||||
estado = conn.execute("SELECT status, last_error FROM orders WHERE sys_id = ?", (sys_id,)).fetchone()
|
||||
docs = conn.execute(
|
||||
"SELECT doc_type, status, sap_doc_num, error_message FROM order_documents WHERE order_sys_id = ? ORDER BY sequence",
|
||||
(sys_id,),
|
||||
).fetchall()
|
||||
print(f"resultado={ok} status={estado['status']} last_error={estado['last_error']}")
|
||||
for d in docs:
|
||||
print(f" {d['doc_type']}: {d['status']} DocNum={d['sap_doc_num']} {d['error_message'] or ''}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
apply = "--apply" in args
|
||||
args = [a for a in args if a != "--apply"]
|
||||
store_code, ids = args[0], [int(x) for x in args[1:]]
|
||||
|
||||
cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json")
|
||||
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||
|
||||
print(f"MODO: {'APPLY (escribe en SAP)' if apply else 'DRY-RUN (no escribe nada)'} | tienda {store_code} | {len(ids)} órdenes")
|
||||
for order_id in ids:
|
||||
print(f"\n===== Orden {order_id} =====")
|
||||
(_apply if apply else (lambda c, s, o: _dry_run(c, s, o)))(cfg, shopify, order_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Dry-run del IncomingPayment: para una o más órdenes de una tienda descarga
|
||||
detalle y transacciones de Shopify (GET), toma la Invoice de SAP si la orden
|
||||
ya está integrada (GET) o una sintética si no, y muestra el JSON de pago que
|
||||
se enviaría. NO escribe en SAP ni en la base de datos.
|
||||
|
||||
Uso: python -m tools.probar_pago <store_code> <shopify_order_id> [...]
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.db import db_session
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from core.shopify_client import ShopifyClient
|
||||
from domain.payment import PaymentConfigError, construir_incoming_payment, transacciones_efectivas
|
||||
|
||||
STORES_DIR = Path(__file__).resolve().parent.parent / "config" / "stores"
|
||||
|
||||
|
||||
def _invoice(sap: SAPServiceLayerClient, store_code: str, order_id: int, detalle: dict) -> tuple[dict, str]:
|
||||
with db_session() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT d.sap_doc_entry FROM order_documents d JOIN orders o ON o.sys_id = d.order_sys_id
|
||||
WHERE o.store_code = ? AND o.shopify_order_id = ? AND d.doc_type = 'INVOICE' AND d.status = 'created'
|
||||
""",
|
||||
(store_code, order_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return sap.forward("GET", f"Invoices({row['sap_doc_entry']})").json(), f"Invoice real DocEntry {row['sap_doc_entry']}"
|
||||
|
||||
sintetica = {
|
||||
"DocEntry": 0,
|
||||
"CardCode": "C99998",
|
||||
"CardName": "(Invoice sintética)",
|
||||
"DocDate": detalle["created_at"][:10],
|
||||
"DocumentLines": [{"Quantity": 1, "PriceAfterVAT": float(detalle["total_price"])}],
|
||||
}
|
||||
return sintetica, "Invoice sintética (orden aún sin Invoice en SAP)"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
store_code, ids = sys.argv[1], [int(x) for x in sys.argv[2:]]
|
||||
cfg = cargar_config_tienda(STORES_DIR / f"{store_code}.json")
|
||||
shopify = ShopifyClient.from_store_config(cfg["shopify"])
|
||||
sap = SAPServiceLayerClient.from_store_config(cfg["sap_connection"])
|
||||
|
||||
for order_id in ids:
|
||||
print(f"\n===== Orden {order_id} =====")
|
||||
detalle = shopify.obtener_orden(order_id)
|
||||
transacciones = shopify.obtener_transacciones(order_id)
|
||||
invoice, origen = _invoice(sap, store_code, order_id, detalle)
|
||||
total = sum(Decimal(str(l["Quantity"])) * Decimal(str(l["PriceAfterVAT"])) for l in invoice["DocumentLines"])
|
||||
|
||||
print(f"{detalle.get('name')} | payment_gateway_names={detalle.get('payment_gateway_names')} | total_price={detalle.get('total_price')}")
|
||||
print(f"{len(transacciones)} transacciones -> {len(transacciones_efectivas(transacciones))} efectivas | {origen} | total Invoice={total}")
|
||||
try:
|
||||
payload = construir_incoming_payment(detalle, transacciones, cfg, invoice, invoice["DocEntry"], float(total))
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
except PaymentConfigError as exc:
|
||||
print(f"ERROR (no se crearía el pago): {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{SAP_URL}}/Login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"CompanyDB": "{{SAP_COMPANY_DB}}",
|
||||
"UserName": "{{SAP_USERNAME}}",
|
||||
"Password": "{{SAP_PASSWORD}}"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{SAP_URL}}/Login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"CompanyDB": "{{SAP_COMPANY_DB}}",
|
||||
"UserName": "{{SAP_USERNAME}}",
|
||||
"Password": "{{SAP_PASSWORD}}"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{SAP_URL}}/Invoices(334)
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{SAP_URL}}/IncomingPayments(47)
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{SAP_URL}}/BusinessPartners('C70000')
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{SAP_URL}}/IncomingPayments(89)
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
###
|
||||
# @name obtenerOrdenes
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerOrdenesFields
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&since_id=7425850048790&limit=50
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerOrden
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7554120581398.json
|
||||
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerTransactions
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders/7429185339670/transactions.json
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerLocations
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/locations.json
|
||||
|
||||
# 90870743318
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerOrdenesPorRangoFecha
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/orders.json?fields=created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags&status=any&created_at_min=2026-08-01T00:00:00-04:00&created_at_max=2026-08-02T23:59:59-04:00&limit=50
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerCliente
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270.json
|
||||
|
||||
|
||||
###
|
||||
# @name obtenerClienteMetafields
|
||||
GET https://{{SHOPIFY_API_ID}}:{{SHOPIFY_ACCESS_TOKEN}}@{{SHOPIFY_STORE}}.myshopify.com/admin/api/{{SHOPIFY_API_VERSION}}/customers/12324710613270/metafields.json
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Utilidad puntual (no forma parte del pipeline recurrente): crea un
|
||||
StockTransfer directo entre dos almacenes para todos los SKU del grupo de
|
||||
artículos indicado.
|
||||
|
||||
Uso:
|
||||
.venv/bin/python tools/transferir_stock.py config/stores/snobbycl.json \
|
||||
--from 8801 --to 7701 --quantity 500
|
||||
|
||||
Agregar --apply para ejecutar el POST real (por defecto es dry-run).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.config import cargar_config_tienda
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from tools.cargar_entrada_mercancia import obtener_skus_grupo, ITEMS_GROUP_CODE
|
||||
|
||||
|
||||
def main(store_config_path: str, from_wh: str, to_wh: str, quantity: float, dry_run: bool) -> None:
|
||||
store_config = cargar_config_tienda(store_config_path)
|
||||
sap = SAPServiceLayerClient.from_store_config(store_config["sap_connection"])
|
||||
|
||||
skus = obtener_skus_grupo(sap, ITEMS_GROUP_CODE)
|
||||
print(f"SKUs encontrados en grupo {ITEMS_GROUP_CODE}: {len(skus)}")
|
||||
|
||||
document_lines = [
|
||||
{
|
||||
"ItemCode": sku,
|
||||
"Quantity": quantity,
|
||||
"FromWarehouseCode": from_wh,
|
||||
"WarehouseCode": to_wh,
|
||||
}
|
||||
for sku in skus
|
||||
]
|
||||
|
||||
payload = {"StockTransferLines": document_lines}
|
||||
|
||||
if dry_run:
|
||||
print(json.dumps(payload["StockTransferLines"][:3], indent=2, ensure_ascii=False))
|
||||
print(f"... ({len(document_lines)} líneas en total, de {from_wh} a {to_wh}, cantidad={quantity})")
|
||||
print("(dry-run: no se envió nada a SAP, correr con --apply para crear el documento)")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = sap.forward("POST", "StockTransfers", json_data=payload)
|
||||
data = resp.json()
|
||||
print(f"StockTransfer creado. DocEntry={data.get('DocEntry')} DocNum={data.get('DocNum')}")
|
||||
except SAPServiceLayerError as exc:
|
||||
print(f"ERROR creando el StockTransfer: {exc.detail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("store_config")
|
||||
parser.add_argument("--from", dest="from_wh", required=True)
|
||||
parser.add_argument("--to", dest="to_wh", required=True)
|
||||
parser.add_argument("--quantity", type=float, required=True)
|
||||
parser.add_argument("--apply", action="store_true", help="Ejecuta el POST real (por defecto es dry-run)")
|
||||
args = parser.parse_args()
|
||||
main(args.store_config, args.from_wh, args.to_wh, args.quantity, dry_run=not args.apply)
|
||||
Reference in new issue
Block a user