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,86 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from domain.items import identificar_skus_faltantes
|
||||
|
||||
|
||||
def diagnosticar_error(sap: SAPServiceLayerClient, exc: SAPServiceLayerError, item_codes: Optional[List[str]] = None) -> str:
|
||||
"""
|
||||
Si el error de SAP corresponde a un tipo conocido, devuelve un mensaje
|
||||
en lenguaje que el usuario entienda; si no, devuelve el mensaje
|
||||
original de SAP tal cual (para no ocultar errores no identificados).
|
||||
"""
|
||||
if "exchange rate" in (exc.detail or "").lower():
|
||||
moneda = re.search(r"'(\w+)'", exc.detail or "")
|
||||
moneda = moneda.group(1) if moneda else "?"
|
||||
return f"No hay tipo de cambio cargado en SAP para la moneda {moneda}"
|
||||
|
||||
if item_codes:
|
||||
faltantes = identificar_skus_faltantes(sap, item_codes)
|
||||
if faltantes:
|
||||
return f"SKU inexistente(s) en SAP: {', '.join(faltantes)} (mensaje original de SAP: {exc.detail})"
|
||||
|
||||
return exc.detail
|
||||
|
||||
|
||||
def buscar_documento_existente(
|
||||
sap: SAPServiceLayerClient,
|
||||
entidad: str,
|
||||
store_code: str,
|
||||
shopify_order_id: Any,
|
||||
campo_lineas: str,
|
||||
lineas_esperadas: Optional[List[Dict[str, Any]]],
|
||||
excluir: Iterable[int] = (),
|
||||
solo_vigentes: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Resguardo de idempotencia contra SAP: busca un documento ya creado para
|
||||
esta orden (UDFs U_ShopifyOrderId + U_ShopifyStore) cuyas líneas
|
||||
(ItemCode, Quantity) coincidan con las que se iban a crear. Sirve cuando
|
||||
la BD local perdió el registro de un documento que sí existe en SAP
|
||||
(evita duplicarlo). Devuelve el documento completo o None.
|
||||
`excluir`: DocEntry que ya están asignados a otro paso de la misma orden.
|
||||
`solo_vigentes`: descarta anulados y documentos de anulación (Invoices).
|
||||
Con `lineas_esperadas=None` se acepta el primer documento encontrado sin
|
||||
comparar líneas (para la Invoice, que es única por orden).
|
||||
"""
|
||||
filtro = f"U_ShopifyOrderId eq '{shopify_order_id}' and U_ShopifyStore eq '{store_code}'"
|
||||
if solo_vigentes:
|
||||
filtro += " and CancelStatus eq 'csNo'"
|
||||
candidatos = sap.forward("GET", entidad, params={"$filter": filtro, "$select": "DocEntry"}).json().get("value", [])
|
||||
|
||||
esperadas = Counter((l["ItemCode"], float(l["Quantity"])) for l in lineas_esperadas) if lineas_esperadas is not None else None
|
||||
excluidos = set(excluir)
|
||||
for candidato in sorted(candidatos, key=lambda c: c["DocEntry"]):
|
||||
if candidato["DocEntry"] in excluidos:
|
||||
continue
|
||||
doc = sap.forward("GET", f"{entidad}({candidato['DocEntry']})").json()
|
||||
if esperadas is None or Counter((l["ItemCode"], float(l["Quantity"])) for l in doc.get(campo_lineas, [])) == esperadas:
|
||||
return doc
|
||||
return None
|
||||
|
||||
|
||||
def crear_documento_sap(
|
||||
sap: SAPServiceLayerClient,
|
||||
entidad: str,
|
||||
payload: Dict[str, Any],
|
||||
item_codes: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
POST genérico a cualquier entidad de documentos de SAP (Invoices,
|
||||
IncomingPayments, InventoryTransferRequests, StockTransfers,
|
||||
DeliveryNotes). Si falla, intenta identificar la causa real (ver
|
||||
diagnosticar_error) antes de relanzar el error con ese mensaje; si no
|
||||
se reconoce el error, se relanza el mensaje original de SAP.
|
||||
"""
|
||||
try:
|
||||
response = sap.forward("POST", entidad, json_data=payload)
|
||||
return response.json()
|
||||
except SAPServiceLayerError as exc:
|
||||
mensaje = diagnosticar_error(sap, exc, item_codes)
|
||||
if mensaje != exc.detail:
|
||||
raise SAPServiceLayerError(exc.status_code, mensaje, code=exc.code) from exc
|
||||
raise
|
||||
Reference in new issue
Block a user