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

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