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,222 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.sap import SAPServiceLayerClient
|
||||
from domain.documents import crear_documento_sap
|
||||
from domain.payment import transacciones_efectivas
|
||||
|
||||
|
||||
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 _precio_unitario_con_descuento(item: Dict[str, Any]) -> float:
|
||||
"""
|
||||
item["price"] es el precio unitario ANTES de descuento. El descuento
|
||||
real de la línea viene en discount_allocations (total_discount no
|
||||
siempre lo refleja). Se descuenta antes de armar PriceAfterVAT.
|
||||
"""
|
||||
cantidad = item.get("quantity") or 1
|
||||
precio_total = float(item["price"]) * cantidad
|
||||
descuento = sum(float(d.get("amount", 0)) for d in item.get("discount_allocations", []))
|
||||
return round((precio_total - descuento) / cantidad, 2)
|
||||
|
||||
|
||||
def _cantidad_cancelada(order: Dict[str, Any], line_item_id: Any) -> int:
|
||||
"""
|
||||
Unidades de una línea canceladas antes de la venta (refund_line_items con
|
||||
restock_type "cancel", ej. líneas eliminadas de la orden antes del pago).
|
||||
Las devoluciones posteriores a la entrega ("return") no se descuentan aquí.
|
||||
"""
|
||||
return sum(
|
||||
int(rli.get("quantity") or 0)
|
||||
for refund in order.get("refunds") or []
|
||||
for rli in refund.get("refund_line_items") or []
|
||||
if rli.get("line_item_id") == line_item_id and rli.get("restock_type") == "cancel"
|
||||
)
|
||||
|
||||
|
||||
def _armar_lineas(order: Dict[str, Any], warehouse_code: str, cantidad_fn) -> List[Dict[str, Any]]:
|
||||
lineas = []
|
||||
for item in order.get("line_items", []):
|
||||
cantidad = cantidad_fn(item)
|
||||
if item.get("sku") and cantidad > 0:
|
||||
lineas.append(
|
||||
{
|
||||
"ItemCode": item["sku"],
|
||||
"Quantity": cantidad,
|
||||
"WarehouseCode": warehouse_code,
|
||||
"PriceAfterVAT": _precio_unitario_con_descuento(item),
|
||||
"TaxCode": "IVA",
|
||||
}
|
||||
)
|
||||
return lineas
|
||||
|
||||
|
||||
def _total(lineas: List[Dict[str, Any]]) -> float:
|
||||
return sum(l["Quantity"] * l["PriceAfterVAT"] for l in lineas)
|
||||
|
||||
|
||||
def _lineas_por_cobrado(
|
||||
order: Dict[str, Any], warehouse_code: str, transactions: Optional[List[Dict[str, Any]]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
La Invoice debe igualar lo realmente cobrado (transacciones efectivas). Se
|
||||
prueban dos juegos de líneas y se usa el que cuadre:
|
||||
1. líneas originales (quantity): ventas normales y órdenes despachadas
|
||||
que se reembolsaron después de cobrar el total (#CL10072, #8029);
|
||||
2. líneas vigentes (current_quantity): líneas canceladas o devueltas que
|
||||
nunca se cobraron (#7998, #CL10144).
|
||||
Si ninguno cuadra se devuelven las originales y el control de suma del pago
|
||||
deja la orden en error. Sin transacciones (API caída) se descuentan solo las
|
||||
canceladas antes de la venta (restock_type "cancel").
|
||||
"""
|
||||
if transactions is None:
|
||||
return _armar_lineas(order, warehouse_code, lambda i: i["quantity"] - _cantidad_cancelada(order, i.get("id")))
|
||||
|
||||
originales = _armar_lineas(order, warehouse_code, lambda i: i["quantity"])
|
||||
cobrado = sum(float(t["amount"]) for t in transacciones_efectivas(transactions))
|
||||
if abs(_total(originales) - cobrado) < 1:
|
||||
return originales
|
||||
|
||||
vigentes = _armar_lineas(
|
||||
order, warehouse_code, lambda i: i["current_quantity"] if i.get("current_quantity") is not None else i["quantity"]
|
||||
)
|
||||
if vigentes and abs(_total(vigentes) - cobrado) < 1:
|
||||
return vigentes
|
||||
return originales
|
||||
|
||||
|
||||
def fulfillments_facturados(fulfillments: List[Dict[str, Any]], invoice: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Limita cada fulfillment a lo que quedó facturado: líneas devueltas o
|
||||
canceladas que no se cobraron (ver _lineas_por_cobrado) no están en la
|
||||
Invoice, y mover o entregar esas unidades rompería el Delivery (la línea
|
||||
base no existe) o dejaría stock transferido sin venta. Por cada SKU se
|
||||
permite como máximo la cantidad de la Invoice, repartida en orden entre los
|
||||
fulfillments; los que quedan sin líneas se omiten.
|
||||
"""
|
||||
permitido: Dict[str, float] = {}
|
||||
for line in invoice["DocumentLines"]:
|
||||
permitido[line["ItemCode"]] = permitido.get(line["ItemCode"], 0) + line["Quantity"]
|
||||
|
||||
resultado = []
|
||||
for fulfillment in fulfillments:
|
||||
lineas = []
|
||||
for item in fulfillment.get("line_items", []):
|
||||
disponible = permitido.get(item.get("sku"), 0)
|
||||
if disponible <= 0:
|
||||
continue
|
||||
cantidad = min(item["quantity"], disponible)
|
||||
permitido[item["sku"]] = disponible - cantidad
|
||||
lineas.append({**item, "quantity": cantidad})
|
||||
if lineas:
|
||||
resultado.append({**fulfillment, "line_items": lineas})
|
||||
return resultado
|
||||
|
||||
|
||||
def _campos_direccion(prefijo: str, direccion: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not direccion:
|
||||
return {}
|
||||
|
||||
campos = {
|
||||
f"{prefijo}Street": direccion.get("address1"),
|
||||
f"{prefijo}Block": direccion.get("address2"),
|
||||
f"{prefijo}Building": direccion.get("phone"),
|
||||
f"{prefijo}City": direccion.get("city"),
|
||||
f"{prefijo}ZipCode": direccion.get("zip"),
|
||||
f"{prefijo}County": direccion.get("province"),
|
||||
}
|
||||
|
||||
if direccion.get("country_code") == "CL":
|
||||
campos[f"{prefijo}Country"] = "CL"
|
||||
|
||||
return campos
|
||||
|
||||
|
||||
def construir_invoice(
|
||||
order: Dict[str, Any],
|
||||
store_config: Dict[str, Any],
|
||||
card_code: str,
|
||||
federal_tax_id: Optional[str] = None,
|
||||
transactions: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST Invoices (factura de reserva). Las líneas se arman
|
||||
por lo realmente cobrado (ver _lineas_por_cobrado). Sobrescribe
|
||||
CardName y direcciones con los datos reales del cliente de Shopify,
|
||||
aunque el CardCode sea el genérico. Si se conoce el RUT (federal_tax_id,
|
||||
en formato SAP "NNNNNNNN-D"), también sobrescribe FederalTaxID, para que
|
||||
el RUT real del cliente quede en la factura aunque se use el CardCode
|
||||
genérico.
|
||||
"""
|
||||
customer = order.get("customer") or {}
|
||||
billing = order.get("billing_address") or {}
|
||||
shipping = order.get("shipping_address") or {}
|
||||
|
||||
card_name = _nombre_completo(billing) or _nombre_completo(customer) or "Cliente Shopify"
|
||||
doc_date = order["created_at"][:10]
|
||||
|
||||
warehouse_code = store_config["sap"]["default_warehouse_invoice"]
|
||||
series = store_config["sap"]["numbering_series"]["INVOICE"]
|
||||
|
||||
document_lines = _lineas_por_cobrado(order, warehouse_code, transactions)
|
||||
|
||||
address_extension = {
|
||||
**_campos_direccion("BillTo", billing),
|
||||
**_campos_direccion("ShipTo", shipping),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"CardCode": card_code,
|
||||
"CardName": card_name,
|
||||
"DocDate": doc_date,
|
||||
"DocDueDate": doc_date,
|
||||
"Series": series,
|
||||
"DocType": "dDocument_Items",
|
||||
"DocCurrency": "CLP",
|
||||
"ReserveInvoice": "tYES",
|
||||
"NumAtCard": order.get("name"),
|
||||
"AddressExtension": address_extension,
|
||||
"U_ShopifyOrderId": order["id"],
|
||||
"U_ShopifyStore": store_config["store_code"],
|
||||
"DocumentLines": document_lines,
|
||||
}
|
||||
|
||||
if federal_tax_id:
|
||||
payload["FederalTaxID"] = federal_tax_id
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def crear_invoice(sap: SAPServiceLayerClient, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""POST Invoices, con diagnóstico de errores conocidos (ver domain/documents.py)."""
|
||||
item_codes = [line["ItemCode"] for line in payload["DocumentLines"]]
|
||||
return crear_documento_sap(sap, "Invoices", payload, item_codes=item_codes)
|
||||
|
||||
|
||||
def agregar_referencia_documento(
|
||||
sap: SAPServiceLayerClient,
|
||||
invoice_doc_entry: int,
|
||||
ref_doc_entry: int,
|
||||
ref_doc_num: int,
|
||||
ref_obj_type: str,
|
||||
) -> None:
|
||||
"""
|
||||
PATCH a la Invoice para dejar registrada la referencia a un documento
|
||||
generado a partir de ella (ITR, StockTransfer, etc.). Lee las
|
||||
DocumentReferences actuales y agrega la nueva, sin pisar las que ya
|
||||
existan (una orden puede tener varios fulfillments -> varios documentos).
|
||||
"""
|
||||
actual = sap.forward("GET", f"Invoices({invoice_doc_entry})", params={"$select": "DocumentReferences"})
|
||||
referencias = actual.json().get("DocumentReferences", [])
|
||||
|
||||
referencias.append(
|
||||
{
|
||||
"RefDocEntr": ref_doc_entry,
|
||||
"RefDocNum": ref_doc_num,
|
||||
"RefObjType": ref_obj_type,
|
||||
}
|
||||
)
|
||||
|
||||
sap.forward("PATCH", f"Invoices({invoice_doc_entry})", json_data={"DocumentReferences": referencias})
|
||||
Reference in new issue
Block a user