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>
87 lines
3.7 KiB
Python
87 lines
3.7 KiB
Python
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
|