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,52 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_env_cargado = False
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Falta una variable de entorno referenciada en la configuración de una tienda."""
|
||||
|
||||
|
||||
def cargar_env() -> None:
|
||||
"""Carga <raíz>/.env una sola vez (no pisa variables que ya estén definidas en el entorno)."""
|
||||
global _env_cargado
|
||||
if not _env_cargado:
|
||||
load_dotenv(ROOT / ".env", override=False)
|
||||
_env_cargado = True
|
||||
|
||||
|
||||
def _expandir(valor: Any, origen: str) -> Any:
|
||||
if isinstance(valor, dict):
|
||||
return {k: _expandir(v, origen) for k, v in valor.items()}
|
||||
if isinstance(valor, list):
|
||||
return [_expandir(v, origen) for v in valor]
|
||||
if isinstance(valor, str):
|
||||
def reemplazo(m: re.Match) -> str:
|
||||
nombre = m.group(1)
|
||||
if nombre not in os.environ:
|
||||
raise ConfigError(f"{origen}: falta la variable de entorno {nombre} (defínala en .env o en el entorno)")
|
||||
return os.environ[nombre]
|
||||
|
||||
return _PLACEHOLDER.sub(reemplazo, valor)
|
||||
return valor
|
||||
|
||||
|
||||
def cargar_config_tienda(path: Path | str) -> Dict[str, Any]:
|
||||
"""
|
||||
Lee el JSON de configuración de una tienda. Cualquier texto con el formato
|
||||
${NOMBRE} se reemplaza por la variable de entorno NOMBRE (del entorno o de
|
||||
.env), para no guardar tokens ni contraseñas en el JSON. Los valores
|
||||
escritos directamente siguen funcionando.
|
||||
"""
|
||||
cargar_env()
|
||||
path = Path(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return _expandir(json.load(f), path.name)
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_sap.db"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
store_code TEXT NOT NULL,
|
||||
shopify_order_id INTEGER NOT NULL,
|
||||
order_number TEXT,
|
||||
tags TEXT,
|
||||
payment_gateway_names TEXT,
|
||||
shopify_created_at TEXT NOT NULL,
|
||||
time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
financial_status TEXT,
|
||||
fulfillment_status TEXT,
|
||||
card_code TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_error TEXT,
|
||||
UNIQUE(store_code, shopify_order_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_fulfillments (
|
||||
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id),
|
||||
shopify_fulfillment_id INTEGER NOT NULL,
|
||||
location_id TEXT,
|
||||
is_store INTEGER NOT NULL,
|
||||
warehouse_code TEXT,
|
||||
time_insert TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
UNIQUE(order_sys_id, shopify_fulfillment_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_documents (
|
||||
sys_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_sys_id INTEGER NOT NULL REFERENCES orders(sys_id),
|
||||
fulfillment_sys_id INTEGER REFERENCES order_fulfillments(sys_id),
|
||||
doc_type TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
required INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
sap_doc_entry INTEGER,
|
||||
sap_doc_num INTEGER,
|
||||
base_doc_entry INTEGER,
|
||||
error_message TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
time_update TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_store ON orders(store_code, shopify_order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillments_order ON order_fulfillments(order_sys_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_order ON order_documents(order_sys_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_status ON order_documents(status);
|
||||
"""
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_session():
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def get_last_shopify_order_id(store_code: str) -> int:
|
||||
"""Id de Shopify más alto ya guardado para esta tienda (0 si no hay ninguna)."""
|
||||
with db_session() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(shopify_order_id) AS max_id FROM orders WHERE store_code = ?",
|
||||
(store_code,),
|
||||
).fetchone()
|
||||
return row["max_id"] or 0
|
||||
|
||||
|
||||
def insert_order_header(store_code: str, header: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Inserta la cabecera de una orden nueva. Devuelve True si se insertó,
|
||||
False si ya existía (store_code + shopify_order_id duplicado).
|
||||
"""
|
||||
with db_session() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO orders (
|
||||
store_code, shopify_order_id, order_number, tags,
|
||||
shopify_created_at, financial_status, fulfillment_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
store_code,
|
||||
header["shopify_order_id"],
|
||||
header.get("order_number"),
|
||||
header.get("tags"),
|
||||
header["shopify_created_at"],
|
||||
header.get("financial_status"),
|
||||
header.get("fulfillment_status"),
|
||||
),
|
||||
)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def mark_orders_timeout(store_code: str, timeout_days: int) -> int:
|
||||
"""
|
||||
Marca como 'timeout' las órdenes de esta tienda que superaron
|
||||
timeout_days desde su creación en Shopify y no llegaron a 'integrated'.
|
||||
Devuelve la cantidad de órdenes marcadas.
|
||||
"""
|
||||
with db_session() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET status = 'timeout', time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE store_code = ?
|
||||
AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled')
|
||||
AND julianday('now') - julianday(shopify_created_at) > ?
|
||||
""",
|
||||
(store_code, timeout_days),
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def get_pending_orders(store_code: str) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Órdenes de esta tienda que aún no llegaron a un estado terminal.
|
||||
Ordenadas por time_update ASC (la que hace más tiempo no se toca,
|
||||
primero): así una orden que falla repetido no bloquea el cupo por
|
||||
ciclo para siempre, porque cada intento le actualiza time_update y la
|
||||
manda al final de la fila hasta la próxima vuelta.
|
||||
"""
|
||||
with db_session() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM orders
|
||||
WHERE store_code = ?
|
||||
AND status NOT IN ('integrated', 'timeout', 'ignored', 'cancelled')
|
||||
ORDER BY time_update ASC, shopify_order_id ASC
|
||||
""",
|
||||
(store_code,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def get_order_sys_id(store_code: str, shopify_order_id: int) -> Optional[int]:
|
||||
with db_session() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sys_id FROM orders WHERE store_code = ? AND shopify_order_id = ?",
|
||||
(store_code, shopify_order_id),
|
||||
).fetchone()
|
||||
return row["sys_id"] if row else None
|
||||
|
||||
|
||||
def ensure_order_document(
|
||||
order_sys_id: int,
|
||||
doc_type: str,
|
||||
sequence: int,
|
||||
fulfillment_sys_id: Optional[int] = None,
|
||||
required: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Devuelve el sys_id de la fila order_documents para (order, fulfillment,
|
||||
doc_type), creándola en estado 'pending' si todavía no existe.
|
||||
"""
|
||||
with db_session() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT sys_id FROM order_documents
|
||||
WHERE order_sys_id = ?
|
||||
AND doc_type = ?
|
||||
AND fulfillment_sys_id IS ?
|
||||
""",
|
||||
(order_sys_id, doc_type, fulfillment_sys_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["sys_id"]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO order_documents (
|
||||
order_sys_id, fulfillment_sys_id, doc_type, sequence, required
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_sys_id, fulfillment_sys_id, doc_type, sequence, int(required)),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def mark_document_error(document_sys_id: int, error_message: str) -> None:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE order_documents
|
||||
SET status = 'error',
|
||||
error_message = ?,
|
||||
attempts = attempts + 1,
|
||||
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE sys_id = ?
|
||||
""",
|
||||
(error_message, document_sys_id),
|
||||
)
|
||||
order_sys_id = conn.execute(
|
||||
"SELECT order_sys_id FROM order_documents WHERE sys_id = ?", (document_sys_id,)
|
||||
).fetchone()["order_sys_id"]
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET status = 'error', last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE sys_id = ?
|
||||
""",
|
||||
(error_message, order_sys_id),
|
||||
)
|
||||
|
||||
|
||||
def mark_document_created(document_sys_id: int, sap_doc_entry: int, sap_doc_num: int) -> None:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE order_documents
|
||||
SET status = 'created',
|
||||
sap_doc_entry = ?,
|
||||
sap_doc_num = ?,
|
||||
error_message = NULL,
|
||||
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE sys_id = ?
|
||||
""",
|
||||
(sap_doc_entry, sap_doc_num, document_sys_id),
|
||||
)
|
||||
|
||||
|
||||
def get_doc_entries(order_sys_id: int, doc_type: str) -> list[int]:
|
||||
"""DocEntry de SAP ya registrados para esta orden y tipo de documento."""
|
||||
with db_session() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT sap_doc_entry FROM order_documents WHERE order_sys_id = ? AND doc_type = ? AND sap_doc_entry IS NOT NULL",
|
||||
(order_sys_id, doc_type),
|
||||
).fetchall()
|
||||
return [r["sap_doc_entry"] for r in rows]
|
||||
|
||||
|
||||
def get_order_document(document_sys_id: int) -> Optional[Dict[str, Any]]:
|
||||
with db_session() as conn:
|
||||
row = conn.execute("SELECT * FROM order_documents WHERE sys_id = ?", (document_sys_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def set_order_status(order_sys_id: int, status: str, last_error: Optional[str] = None) -> None:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET status = ?, last_error = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE sys_id = ?
|
||||
""",
|
||||
(status, last_error, order_sys_id),
|
||||
)
|
||||
|
||||
|
||||
def set_order_card_code(order_sys_id: int, card_code: str) -> None:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
"UPDATE orders SET card_code = ?, time_update = strftime('%Y-%m-%d %H:%M:%f','now') WHERE sys_id = ?",
|
||||
(card_code, order_sys_id),
|
||||
)
|
||||
|
||||
|
||||
def update_order_snapshot(order_sys_id: int, order: Dict[str, Any]) -> None:
|
||||
"""Refresca los campos de Shopify que pueden cambiar entre corridas."""
|
||||
gateways = order.get("payment_gateway_names") or []
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET financial_status = ?,
|
||||
fulfillment_status = ?,
|
||||
tags = ?,
|
||||
payment_gateway_names = ?,
|
||||
time_update = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE sys_id = ?
|
||||
""",
|
||||
(
|
||||
order.get("financial_status"),
|
||||
order.get("fulfillment_status"),
|
||||
order.get("tags"),
|
||||
" + ".join(gateways) if gateways else None,
|
||||
order_sys_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def ensure_order_fulfillment(
|
||||
order_sys_id: int,
|
||||
shopify_fulfillment_id: int,
|
||||
location_id: str,
|
||||
is_store: bool,
|
||||
warehouse_code: str,
|
||||
) -> int:
|
||||
"""
|
||||
Devuelve el sys_id de order_fulfillments para este fulfillment,
|
||||
creándolo si todavía no existe (una orden puede recibir fulfillments
|
||||
nuevos en corridas posteriores).
|
||||
"""
|
||||
with db_session() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sys_id FROM order_fulfillments WHERE order_sys_id = ? AND shopify_fulfillment_id = ?",
|
||||
(order_sys_id, shopify_fulfillment_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["sys_id"]
|
||||
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO order_fulfillments (
|
||||
order_sys_id, shopify_fulfillment_id, location_id, is_store, warehouse_code
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_sys_id, shopify_fulfillment_id, location_id, int(is_store), warehouse_code),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
@@ -0,0 +1,13 @@
|
||||
class SAPServiceLayerError(Exception):
|
||||
def __init__(self, status_code: int, detail: str, code: int = None):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.code = code
|
||||
super().__init__(f"[{status_code}] {detail}")
|
||||
|
||||
|
||||
class ShopifyAPIError(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(f"[{status_code}] {detail}")
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
LOCK_PATH = Path(__file__).resolve().parent.parent / "data" / "shopify_runrun.lock"
|
||||
|
||||
|
||||
class SingleInstanceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def acquire_single_instance_lock() -> FileLock:
|
||||
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = FileLock(str(LOCK_PATH), timeout=0)
|
||||
try:
|
||||
lock.acquire()
|
||||
except Timeout:
|
||||
raise SingleInstanceError(
|
||||
f"Ya existe una instancia en ejecución (lock: {LOCK_PATH})"
|
||||
)
|
||||
return lock
|
||||
@@ -0,0 +1,183 @@
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
LOG_FILE = LOG_DIR / "shopify-sap.log"
|
||||
EVENTS_FILE = LOG_DIR / "events.jsonl"
|
||||
_LOG_CONFIGURED = False
|
||||
|
||||
_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[])
|
||||
_order_ctx: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar("order_ctx", default=None)
|
||||
|
||||
# ============================================================================
|
||||
# Contexto de orden: taggea automáticamente los logs dentro del bloque con
|
||||
# store_code/shopify_order_id, sin tener que repetirlos en cada llamada.
|
||||
@contextmanager
|
||||
def order_context(store_code: str, shopify_order_id: int):
|
||||
token = _order_ctx.set({"store_code": store_code, "shopify_order_id": shopify_order_id})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_order_ctx.reset(token)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class TreeFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
ctx = _order_ctx.get()
|
||||
tag = f"[{ctx['store_code']}#{ctx['shopify_order_id']}] " if ctx else ""
|
||||
return f"{self.formatTime(record)} ─── {tag}{record.getMessage()}"
|
||||
|
||||
def formatTime(self, record, datefmt=None):
|
||||
return datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def _build_prefix(stack, is_last):
|
||||
if not stack:
|
||||
return ""
|
||||
parts = []
|
||||
for sib_is_last in stack[:-1]:
|
||||
parts.append(" " if sib_is_last else "│ ")
|
||||
parts.append("└── " if is_last else "├── ")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class _TaskContext:
|
||||
def __init__(self, logger, msg, emoji, is_last):
|
||||
self._logger = logger
|
||||
self._msg = msg
|
||||
self._emoji = emoji
|
||||
self._is_last = is_last
|
||||
|
||||
def __enter__(self):
|
||||
stack = _stack.get()
|
||||
prefix = _build_prefix(stack, self._is_last)
|
||||
emoji_str = f"{self._emoji} " if self._emoji else ""
|
||||
self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}")
|
||||
_stack.set(stack + [self._is_last])
|
||||
return TaskLogger(self._logger._logger)
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
_stack.set(_stack.get()[:-1])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class TaskLogger:
|
||||
def __init__(self, logger):
|
||||
self._logger = logger
|
||||
|
||||
@contextmanager
|
||||
def task(self, msg, emoji=""):
|
||||
with _TaskContext(self, msg, emoji, is_last=False):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def last_task(self, msg, emoji=""):
|
||||
with _TaskContext(self, msg, emoji, is_last=True):
|
||||
yield
|
||||
|
||||
def info(self, msg, *args, _last=False, **kwargs):
|
||||
self._log(logging.INFO, msg, args, kwargs, _last)
|
||||
|
||||
def warning(self, msg, *args, _last=False, **kwargs):
|
||||
self._log(logging.WARNING, msg, args, kwargs, _last)
|
||||
|
||||
def error(self, msg, *args, _last=False, **kwargs):
|
||||
self._log(logging.ERROR, msg, args, kwargs, _last)
|
||||
|
||||
def _log(self, level, msg, args, kwargs, is_last):
|
||||
stack = _stack.get()
|
||||
prefix = _build_prefix(stack, is_last)
|
||||
self._logger.log(level, f"{prefix}{msg}", *args, **kwargs)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def setup_logging(level=None, console=True):
|
||||
global _LOG_CONFIGURED
|
||||
if _LOG_CONFIGURED:
|
||||
return
|
||||
|
||||
if level is None:
|
||||
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
formatter = TreeFormatter()
|
||||
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
LOG_FILE,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
handlers = [file_handler]
|
||||
if console:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
handlers.append(console_handler)
|
||||
|
||||
logging.basicConfig(level=level, handlers=handlers, force=True)
|
||||
_LOG_CONFIGURED = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def get_logger(name="shopify-sap"):
|
||||
setup_logging()
|
||||
return TaskLogger(logging.getLogger(name))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Log de eventos de negocio (JSON Lines), separado del log técnico en árbol.
|
||||
# Pensado para alimentar más adelante un visor de eventos de integración.
|
||||
_events_logger: Optional[logging.Logger] = None
|
||||
|
||||
|
||||
def _get_events_logger() -> logging.Logger:
|
||||
global _events_logger
|
||||
if _events_logger is not None:
|
||||
return _events_logger
|
||||
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
handler = TimedRotatingFileHandler(
|
||||
EVENTS_FILE,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
|
||||
logger = logging.getLogger("shopify-sap-events")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
|
||||
_events_logger = logger
|
||||
return logger
|
||||
|
||||
|
||||
def log_event(event: str, level: str = "info", message: str = "", **fields) -> None:
|
||||
ctx = _order_ctx.get() or {}
|
||||
payload = {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"level": level.upper(),
|
||||
"event": event,
|
||||
"message": message,
|
||||
**ctx,
|
||||
**fields,
|
||||
}
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
_get_events_logger().log(log_level, json.dumps(payload, ensure_ascii=False, default=str))
|
||||
@@ -0,0 +1,80 @@
|
||||
import html
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.db import db_session
|
||||
|
||||
REPORTS_DIR = Path(__file__).resolve().parent.parent / "notifications"
|
||||
|
||||
|
||||
def obtener_errores_pendientes(store_code: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
query = """
|
||||
SELECT o.store_code, o.shopify_order_id, o.order_number,
|
||||
d.doc_type, d.attempts, d.error_message, d.time_update
|
||||
FROM order_documents d
|
||||
JOIN orders o ON o.sys_id = d.order_sys_id
|
||||
WHERE d.status = 'error'
|
||||
"""
|
||||
params: tuple = ()
|
||||
if store_code:
|
||||
query += " AND o.store_code = ?"
|
||||
params = (store_code,)
|
||||
query += " ORDER BY d.time_update DESC"
|
||||
|
||||
with db_session() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def generar_reporte_errores_html(store_code: Optional[str] = None) -> Path:
|
||||
"""
|
||||
Genera un HTML (tabla) con los order_documents en estado 'error' y lo
|
||||
guarda en notifications/. Por ahora solo se guarda en disco; el envío
|
||||
(mail/mensaje) se implementa más adelante.
|
||||
"""
|
||||
errores = obtener_errores_pendientes(store_code)
|
||||
|
||||
filas = "\n".join(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(e['store_code'])}</td>"
|
||||
f"<td>{html.escape(e['order_number'] or '')}</td>"
|
||||
f"<td>{e['shopify_order_id']}</td>"
|
||||
f"<td>{html.escape(e['doc_type'])}</td>"
|
||||
f"<td>{e['attempts']}</td>"
|
||||
f"<td>{html.escape(e['error_message'] or '')}</td>"
|
||||
f"<td>{html.escape(e['time_update'])}</td>"
|
||||
"</tr>"
|
||||
for e in errores
|
||||
)
|
||||
|
||||
contenido = f"""<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Errores de integración Shopify -> SAP</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; font-size: 14px; color: #222; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; vertical-align: top; }}
|
||||
th {{ background-color: #f2f2f2; }}
|
||||
tr:nth-child(even) {{ background-color: #fafafa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Errores de integración Shopify -> SAP</h2>
|
||||
<p>Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Total: {len(errores)}</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Tienda</th><th>Orden</th><th>Shopify ID</th><th>Documento</th><th>Intentos</th><th>Error</th><th>Última actualización</th>
|
||||
</tr>
|
||||
{filas}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
archivo = REPORTS_DIR / f"errores_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
||||
archivo.write_text(contenido, encoding="utf-8")
|
||||
return archivo
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.errors import SAPServiceLayerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SAPServiceLayerClient:
|
||||
"""
|
||||
Cliente de SAP B1 Service Layer para una conexión (URL + CompanyDB)
|
||||
específica. Cada tienda Shopify puede apuntar a una base SAP distinta,
|
||||
por eso la sesión (B1SESSION) vive por instancia y no en un estado
|
||||
global del módulo.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
company_db: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: float = 15.0,
|
||||
verify_ssl: bool = False,
|
||||
):
|
||||
self.url = url.rstrip("/")
|
||||
self.company_db = company_db
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.verify_ssl = verify_ssl
|
||||
self._headers: Optional[Dict[str, str]] = None
|
||||
|
||||
@classmethod
|
||||
def from_store_config(cls, sap_connection: Dict[str, Any]) -> "SAPServiceLayerClient":
|
||||
"""
|
||||
Construye el cliente a partir del bloque "sap_connection" del JSON
|
||||
de configuración de una tienda.
|
||||
"""
|
||||
return cls(
|
||||
url=sap_connection["url"],
|
||||
company_db=sap_connection["company_db"],
|
||||
username=sap_connection["username"],
|
||||
password=sap_connection["password"],
|
||||
timeout=float(sap_connection.get("timeout", 15.0)),
|
||||
verify_ssl=bool(sap_connection.get("verify_ssl", False)),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
def _log_response(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
response: requests.Response,
|
||||
request_data: Optional[Dict] = None,
|
||||
) -> None:
|
||||
extra = {
|
||||
"http_method": method,
|
||||
"endpoint": endpoint,
|
||||
"status_code": response.status_code,
|
||||
"company_db": self.company_db,
|
||||
}
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
error_detail = response.json()
|
||||
respuesta = json.dumps(error_detail, indent=2)[:500]
|
||||
except Exception:
|
||||
respuesta = response.text[:500]
|
||||
logger.error(
|
||||
f"[{self.company_db}] [{response.status_code}] {method} {endpoint} - "
|
||||
f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - "
|
||||
f"Respuesta: {respuesta}",
|
||||
extra=extra,
|
||||
)
|
||||
else:
|
||||
logger.info(f"[{self.company_db}] [{response.status_code}] {method} {endpoint}", extra=extra)
|
||||
|
||||
# ========================================================================
|
||||
def ensure_session(self, force_refresh: bool = False) -> Dict[str, str]:
|
||||
"""
|
||||
Verifica si existe una sesión (B1SESSION) para esta conexión.
|
||||
Si no existe o force_refresh es True, hace Login.
|
||||
"""
|
||||
if self._headers and not force_refresh:
|
||||
return self._headers
|
||||
|
||||
logger.info(f"[{self.company_db}] Iniciando sesión en SAP Service Layer...")
|
||||
auth_url = f"{self.url}/Login"
|
||||
payload = {
|
||||
"CompanyDB": self.company_db,
|
||||
"UserName": self.username,
|
||||
"Password": self.password,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
auth_url,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"[{self.company_db}] Error crítico: no se pudo iniciar sesión en SAP. "
|
||||
f"Status: {response.status_code}"
|
||||
)
|
||||
self._headers = None
|
||||
raise SAPServiceLayerError(response.status_code, "Conexión fallida con el servidor SAP")
|
||||
|
||||
sap_session = response.json()
|
||||
b1_session_id = sap_session.get("SessionId")
|
||||
route_id = response.cookies.get("ROUTEID")
|
||||
|
||||
headers = {"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"}
|
||||
self._headers = headers
|
||||
logger.info(f"[{self.company_db}] Sesión establecida. B1SESSION: {b1_session_id[:10]}...")
|
||||
return headers
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"[{self.company_db}] Error de conexión al autenticar contra SAP: {exc}")
|
||||
self._headers = None
|
||||
raise SAPServiceLayerError(500, f"Error al conectar con SAP Service Layer: {exc}")
|
||||
|
||||
# ========================================================================
|
||||
def forward(
|
||||
self,
|
||||
method: str,
|
||||
endpoint_path: str,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Realiza una llamada a SAP B1 Service Layer, manejando autenticación
|
||||
transparente con sesión única (por instancia) y reintento en 401.
|
||||
"""
|
||||
if not method or not endpoint_path:
|
||||
raise ValueError("method y endpoint_path son requeridos")
|
||||
|
||||
method = method.upper()
|
||||
headers = self.ensure_session()
|
||||
url_destino = f"{self.url}/{endpoint_path}"
|
||||
|
||||
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
req_headers.update(headers)
|
||||
if extra_headers:
|
||||
req_headers.update(extra_headers)
|
||||
|
||||
log_extra = {"http_method": method, "endpoint": endpoint_path, "company_db": self.company_db}
|
||||
logger.info(f"[{self.company_db}] [{method}] {endpoint_path}", extra=log_extra)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=req_headers,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"[{self.company_db}] Timeout ({self.timeout}s) en {endpoint_path}", extra=log_extra)
|
||||
raise ConnectionError(f"Timeout al conectar con {endpoint_path}")
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
logger.error(f"[{self.company_db}] Error de conexión: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"[{self.company_db}] Error en solicitud: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning(
|
||||
f"[{self.company_db}] Sesión expirada (401). Reautenticando e intentando una sola vez...",
|
||||
extra=log_extra,
|
||||
)
|
||||
try:
|
||||
headers = self.ensure_session(force_refresh=True)
|
||||
req_headers.update(headers)
|
||||
|
||||
logger.info(f"[{self.company_db}] Reintentando solicitud [RETRY]", extra=log_extra)
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=req_headers,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[{self.company_db}] Error en reintento: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"Error en reintento tras reautenticación: {exc}")
|
||||
|
||||
self._log_response(method, endpoint_path, response, json_data)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = response.text
|
||||
error_code = None
|
||||
try:
|
||||
err = response.json()
|
||||
error_msg = err.get("error", {}).get("message", {}).get("value", error_msg)
|
||||
error_code = err.get("error", {}).get("code")
|
||||
except Exception:
|
||||
pass
|
||||
raise SAPServiceLayerError(response.status_code, error_msg, code=error_code)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import truststore
|
||||
|
||||
# Verifica certificados usando el almacén de confianza del sistema operativo
|
||||
# en vez del bundle estático de certifi. Necesario detrás de firewalls que
|
||||
# hacen inspección TLS (ej. Fortinet) y, en el servicio Windows, para
|
||||
# confiar automáticamente en los CA que administre IT.
|
||||
truststore.inject_into_ssl()
|
||||
|
||||
import requests
|
||||
|
||||
from core.errors import ShopifyAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NETWORK_RETRIES = 4 # intentos totales ante fallos de red
|
||||
NETWORK_BACKOFF = 2 # segundos; espera = backoff * nº de intento
|
||||
|
||||
ORDER_HEADER_FIELDS ="created_at,id,gateway,order_number,name,financial_status,fulfillment_status,total_price,tags"
|
||||
|
||||
|
||||
class ShopifyClient:
|
||||
"""
|
||||
Cliente de la Admin API de Shopify para una tienda específica.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: str,
|
||||
api_id: str,
|
||||
access_token: str,
|
||||
api_version: str,
|
||||
timeout: float = 30.0,
|
||||
):
|
||||
self.store = store
|
||||
self.base_url = f"https://{store}.myshopify.com/admin/api/{api_version}"
|
||||
self.auth = (api_id, access_token)
|
||||
self.timeout = timeout
|
||||
|
||||
@classmethod
|
||||
def from_store_config(cls, shopify_config: Dict[str, Any]) -> "ShopifyClient":
|
||||
return cls(
|
||||
store=shopify_config["store"],
|
||||
api_id=shopify_config["api_id"],
|
||||
access_token=shopify_config["access_token"],
|
||||
api_version=shopify_config["api_version"],
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
def _request(self, url: str, params: Dict[str, Any]) -> requests.Response:
|
||||
"""
|
||||
GET con reintentos ante fallos transitorios de red (SSL EOF, reset de
|
||||
conexión, timeout), con espera creciente. Los errores HTTP (4xx/5xx) no
|
||||
se reintentan aquí: los maneja _get.
|
||||
"""
|
||||
for intento in range(1, NETWORK_RETRIES + 1):
|
||||
try:
|
||||
return requests.get(url, params=params, auth=self.auth, timeout=self.timeout)
|
||||
except requests.RequestException as exc:
|
||||
if intento == NETWORK_RETRIES:
|
||||
raise
|
||||
espera = NETWORK_BACKOFF * intento
|
||||
logger.warning(f"[{self.store}] Fallo de red ({type(exc).__name__}), reintento {intento}/{NETWORK_RETRIES - 1} en {espera}s")
|
||||
time.sleep(espera)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any]) -> requests.Response:
|
||||
url = f"{self.base_url}/{path}"
|
||||
|
||||
response = self._request(url, params)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = float(response.headers.get("Retry-After", 2))
|
||||
logger.warning(f"[{self.store}] Rate limit alcanzado, reintentando en {retry_after}s")
|
||||
time.sleep(retry_after)
|
||||
response = self._request(url, params)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise ShopifyAPIError(response.status_code, response.text[:500])
|
||||
|
||||
return response
|
||||
|
||||
# ========================================================================
|
||||
def obtener_cabeceras_ordenes(self, since_id: int = 0, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Descarga cabeceras de órdenes (status=any) con id mayor a since_id,
|
||||
ordenadas ascendente por id (comportamiento por defecto de Shopify).
|
||||
"""
|
||||
params = {
|
||||
"fields": ORDER_HEADER_FIELDS,
|
||||
"status": "any",
|
||||
"since_id": since_id,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
logger.info(f"[{self.store}] GET orders.json since_id={since_id} limit={limit}")
|
||||
response = self._get("orders.json", params)
|
||||
return response.json().get("orders", [])
|
||||
|
||||
# ========================================================================
|
||||
def obtener_orden(self, shopify_order_id: int) -> Dict[str, Any]:
|
||||
"""Descarga el detalle completo (cabecera + line_items) de una orden."""
|
||||
logger.info(f"[{self.store}] GET orders/{shopify_order_id}.json")
|
||||
response = self._get(f"orders/{shopify_order_id}.json", params={})
|
||||
return response.json().get("order", {})
|
||||
|
||||
# ========================================================================
|
||||
def obtener_transacciones(self, shopify_order_id: int) -> List[Dict[str, Any]]:
|
||||
"""Transacciones de pago de la orden (monto, gateway, estado, tarjeta)."""
|
||||
logger.info(f"[{self.store}] GET orders/{shopify_order_id}/transactions.json")
|
||||
response = self._get(f"orders/{shopify_order_id}/transactions.json", params={})
|
||||
return response.json().get("transactions", [])
|
||||
Reference in new issue
Block a user