Files
shopify_run_run/tools/consultar.py
T
georgeandClaude Sonnet 5.5 901dd8763d 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>
2026-09-30 19:22:40 -05:00

201 lines
9.1 KiB
Python

"""
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()