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,117 @@
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from domain.rut import formatear_rut, normalizar_rut, validar_rut
|
||||
|
||||
RUT_PATTERN = re.compile(r"^\d{1,2}\.?\d{3}\.?\d{3}-?[\dkK]$")
|
||||
BUSINESS_PARTNER_GROUP_CODE = 100 # "Clientes", mismo grupo que usa el CardCode genérico
|
||||
|
||||
|
||||
def _nombre_completo(persona: Dict[str, Any]) -> str:
|
||||
nombre = f"{persona.get('first_name', '') or ''} {persona.get('last_name', '') or ''}".strip()
|
||||
return nombre or persona.get("name", "") or ""
|
||||
|
||||
|
||||
def buscar_rut_en_order(order: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Busca el RUT en note_attributes de la orden, donde algunas tiendas lo
|
||||
capturan en el checkout (ej. {"name": "rut", "value": "11111109-K"}).
|
||||
No requiere llamada extra a Shopify: ya viene en el detalle de la orden.
|
||||
"""
|
||||
for attr in order.get("note_attributes") or []:
|
||||
nombre = str(attr.get("name", "")).strip().lower()
|
||||
if "rut" not in nombre:
|
||||
continue
|
||||
valor = str(attr.get("value", "")).strip()
|
||||
if RUT_PATTERN.match(valor) and validar_rut(valor):
|
||||
return normalizar_rut(valor)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _bp_address(tipo: str, nombre: str, direccion: Dict[str, Any]) -> Dict[str, Any]:
|
||||
address = {
|
||||
"AddressName": nombre,
|
||||
"AddressType": tipo,
|
||||
"Street": direccion.get("address1"),
|
||||
"Block": direccion.get("address2"),
|
||||
"City": direccion.get("city"),
|
||||
"ZipCode": direccion.get("zip"),
|
||||
"County": direccion.get("province"),
|
||||
}
|
||||
if direccion.get("country_code") == "CL":
|
||||
address["Country"] = "CL"
|
||||
if tipo == "bo_ShipTo":
|
||||
address["TaxCode"] = "IVA"
|
||||
return address
|
||||
|
||||
|
||||
def crear_business_partner(sap: SAPServiceLayerClient, rut_sap: str, order: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Crea un BusinessPartner (cliente) nuevo para este RUT. CardCode = "C" +
|
||||
rut_sap. Devuelve el CardCode creado.
|
||||
"""
|
||||
billing = order.get("billing_address") or {}
|
||||
shipping = order.get("shipping_address") or {}
|
||||
customer = order.get("customer") or {}
|
||||
card_code = f"C{rut_sap}"
|
||||
|
||||
payload = {
|
||||
"CardCode": card_code,
|
||||
"CardName": _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify",
|
||||
"CardType": "cCustomer",
|
||||
"GroupCode": BUSINESS_PARTNER_GROUP_CODE,
|
||||
"FederalTaxID": rut_sap,
|
||||
"Phone1": billing.get("phone") or customer.get("phone"),
|
||||
"EmailAddress": order.get("email") or order.get("contact_email"),
|
||||
"BPAddresses": [
|
||||
_bp_address("bo_BillTo", "BillTo", billing),
|
||||
_bp_address("bo_ShipTo", "ShipTo", shipping or billing),
|
||||
],
|
||||
}
|
||||
|
||||
sap.forward("POST", "BusinessPartners", json_data=payload)
|
||||
return card_code
|
||||
|
||||
|
||||
def resolver_card_code(
|
||||
sap: SAPServiceLayerClient,
|
||||
store_config: Dict[str, Any],
|
||||
rut: Optional[str],
|
||||
order: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Si hay un RUT válido, busca el BusinessPartner en SAP por FederalTaxID
|
||||
(con guión, que es el formato que usa SAP: "NNNNNNNN-D"). Si existe,
|
||||
devuelve su CardCode. Si no existe, crea uno nuevo (CardCode "C"+rut).
|
||||
Si no hay RUT, o falla la creación, usa el CardCode genérico
|
||||
configurado para la tienda.
|
||||
|
||||
Siempre devuelve "rut_sap" (el RUT en formato SAP) cuando hay RUT, para
|
||||
poder sobrescribir el FederalTaxID del documento aunque se use el
|
||||
CardCode genérico.
|
||||
"""
|
||||
generic_card_code = store_config["sap"]["generic_card_code"]
|
||||
|
||||
if not rut:
|
||||
return {"card_code": generic_card_code, "rut": None, "rut_sap": None, "encontrado_por_rut": False}
|
||||
|
||||
rut_sap = formatear_rut(rut)
|
||||
|
||||
resp = sap.forward(
|
||||
"GET",
|
||||
"BusinessPartners",
|
||||
params={"$select": "CardCode", "$filter": f"FederalTaxID eq '{rut_sap}'"},
|
||||
)
|
||||
resultados = resp.json().get("value", [])
|
||||
|
||||
if resultados:
|
||||
return {"card_code": resultados[0]["CardCode"], "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True}
|
||||
|
||||
try:
|
||||
card_code = crear_business_partner(sap, rut_sap, order or {})
|
||||
return {"card_code": card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": True}
|
||||
except SAPServiceLayerError:
|
||||
return {"card_code": generic_card_code, "rut": rut, "rut_sap": rut_sap, "encontrado_por_rut": False}
|
||||
Reference in new issue
Block a user