Files
shopify_run_run/domain/documents.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

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