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}
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
INVOICE_BASE_TYPE = 13 # ObjType clásico de Sales Invoice (oInvoices) en SAP B1
|
||||
|
||||
|
||||
def construir_delivery(invoice: Dict[str, Any], stock_transfer: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST DeliveryNotes con la Invoice de reserva como base
|
||||
(BaseType/BaseEntry/BaseLine). Solo se entregan los ítems que
|
||||
efectivamente se transfirieron en este fulfillment (StockTransferLines),
|
||||
emparejados con la línea correspondiente de la Invoice por ItemCode.
|
||||
"""
|
||||
invoice_lines_por_sku = {line["ItemCode"]: line for line in invoice["DocumentLines"]}
|
||||
|
||||
document_lines = []
|
||||
for st_line in stock_transfer["StockTransferLines"]:
|
||||
inv_line = invoice_lines_por_sku[st_line["ItemCode"]]
|
||||
document_lines.append(
|
||||
{
|
||||
"ItemCode": st_line["ItemCode"],
|
||||
"Quantity": st_line["Quantity"],
|
||||
"WarehouseCode": inv_line["WarehouseCode"],
|
||||
"BaseType": INVOICE_BASE_TYPE,
|
||||
"BaseEntry": invoice["DocEntry"],
|
||||
"BaseLine": inv_line["LineNum"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"CardCode": invoice.get("CardCode"),
|
||||
"CardName": invoice.get("CardName"),
|
||||
"Comments": invoice.get("NumAtCard"),
|
||||
"DocDate": invoice["DocDate"][:10],
|
||||
"Series": store_config["sap"]["numbering_series"]["DELIVERY"],
|
||||
"U_ShopifyOrderId": invoice.get("U_ShopifyOrderId"),
|
||||
"U_ShopifyStore": invoice.get("U_ShopifyStore"),
|
||||
"DocumentLines": document_lines,
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def resolver_location(store_config: Dict[str, Any], location_id: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Config (is_store/warehouse_code) para un location_id de Shopify, o None si no está mapeado."""
|
||||
return store_config["sap"]["locations"].get(str(location_id))
|
||||
|
||||
|
||||
def es_tienda(store_config: Dict[str, Any], location_id: Any) -> bool:
|
||||
"""
|
||||
True si este location_id es una tienda física (genera StockTransfer +
|
||||
Delivery además de la ITR); False si es un CD (solo ITR).
|
||||
"""
|
||||
location = resolver_location(store_config, location_id)
|
||||
return bool(location and location.get("is_store"))
|
||||
|
||||
|
||||
def construir_inventory_transfer_request(
|
||||
order: Dict[str, Any],
|
||||
fulfillment: Dict[str, Any],
|
||||
store_config: Dict[str, Any],
|
||||
warehouse_code: str,
|
||||
invoice: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST InventoryTransferRequests: una solicitud por cada
|
||||
fulfillment, moviendo stock desde la bodega real del location_id hacia
|
||||
la bodega virtual usada en el Invoice (default_warehouse_invoice). Usa
|
||||
los line_items propios del fulfillment (no los de toda la orden), ya
|
||||
que una orden puede tener varios fulfillments con distintos
|
||||
ítems/cantidades. CardCode/CardName se toman del Invoice; Comments lleva
|
||||
el "name" de Shopify para poder ubicarla fácil.
|
||||
"""
|
||||
to_warehouse = store_config["sap"]["default_warehouse_invoice"]
|
||||
series = store_config["sap"]["numbering_series"]["INVENTORY_TRANSFER_REQUEST"]
|
||||
doc_date = order["created_at"][:10]
|
||||
|
||||
document_lines = [
|
||||
{
|
||||
"ItemCode": item["sku"],
|
||||
"Quantity": item["quantity"],
|
||||
"FromWarehouseCode": warehouse_code,
|
||||
"WarehouseCode": to_warehouse,
|
||||
}
|
||||
for item in fulfillment.get("line_items", [])
|
||||
if item.get("sku")
|
||||
]
|
||||
|
||||
return {
|
||||
"CardCode": invoice["CardCode"],
|
||||
"CardName": invoice["CardName"],
|
||||
"Comments": order.get("name"),
|
||||
"DocDate": doc_date,
|
||||
"DueDate": doc_date,
|
||||
"Series": series,
|
||||
"FromWarehouse": warehouse_code,
|
||||
"ToWarehouse": to_warehouse,
|
||||
"U_ShopifyOrderId": order["id"],
|
||||
"U_ShopifyStore": store_config["store_code"],
|
||||
"StockTransferLines": document_lines,
|
||||
}
|
||||
@@ -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})
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import List
|
||||
from urllib.parse import quote
|
||||
|
||||
from core.errors import SAPServiceLayerError
|
||||
from core.sap import SAPServiceLayerClient
|
||||
|
||||
|
||||
def _item_key(sku: str) -> str:
|
||||
escaped = quote(sku.replace("'", "''"), safe="")
|
||||
return f"Items('{escaped}')"
|
||||
|
||||
|
||||
def item_existe(sap: SAPServiceLayerClient, sku: str) -> bool:
|
||||
try:
|
||||
sap.forward("GET", _item_key(sku))
|
||||
return True
|
||||
except SAPServiceLayerError as exc:
|
||||
if exc.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def identificar_skus_faltantes(sap: SAPServiceLayerClient, skus: List[str]) -> List[str]:
|
||||
"""
|
||||
Revisa cada SKU contra SAP (GET Items) y devuelve los que no existen.
|
||||
Se usa cuando un POST de Invoice falla con el código -5009
|
||||
("Item number is missing"), que no indica cuál ItemCode es el problema.
|
||||
"""
|
||||
return [sku for sku in skus if not item_existe(sap, sku)]
|
||||
@@ -0,0 +1,136 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List
|
||||
|
||||
CARD_LAST_DIGITS = "1234"
|
||||
CARD_VALID_UNTIL = "2030-12-31" # 12/30 para todas las tarjetas
|
||||
VOUCHER_MAX_LEN = 20 # SAP rechaza VoucherNum más largo; VoucherNum = name de la orden de Shopify
|
||||
|
||||
|
||||
class PaymentConfigError(Exception):
|
||||
"""El pago no se puede armar (medio sin configurar, montos que no cuadran, etc.)."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def transacciones_efectivas(transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Transacciones que representan dinero realmente cobrado: status success y
|
||||
kind sale/capture. Se ignoran failure/pending y refund/void. Si una
|
||||
authorization tiene su capture (parent_id), solo cuenta la capture.
|
||||
"""
|
||||
exitosas = [t for t in transactions if t.get("status") == "success"]
|
||||
autorizaciones_capturadas = {t.get("parent_id") for t in exitosas if t.get("kind") == "capture" and t.get("parent_id")}
|
||||
|
||||
efectivas = []
|
||||
for t in exitosas:
|
||||
if t.get("kind") == "sale":
|
||||
efectivas.append(t)
|
||||
elif t.get("kind") == "capture":
|
||||
efectivas.append(t)
|
||||
# authorization sin capture y el resto de kinds no suman
|
||||
return [t for t in efectivas if t.get("id") not in autorizaciones_capturadas]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def _metodo(store_config: Dict[str, Any], gateway: str) -> Dict[str, Any]:
|
||||
metodos = store_config["sap"].get("payment_methods") or {}
|
||||
metodo = metodos.get(gateway)
|
||||
if not metodo:
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' no está configurado en sap.payment_methods")
|
||||
return metodo
|
||||
|
||||
|
||||
def construir_incoming_payment(
|
||||
order: Dict[str, Any],
|
||||
transactions: List[Dict[str, Any]],
|
||||
store_config: Dict[str, Any],
|
||||
invoice: Dict[str, Any],
|
||||
invoice_doc_entry: int,
|
||||
invoice_total: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST IncomingPayments, aplicado a la Invoice de reserva
|
||||
ya creada (CardCode, CardName y fecha salen de esa Invoice).
|
||||
|
||||
Los medios de pago salen de las transacciones efectivas (no de
|
||||
payment_gateway_names): cada una según sap.payment_methods[gateway]:
|
||||
- credit_card -> una línea en PaymentCreditCards
|
||||
- transfer -> suma a TransferSum con su transfer_account
|
||||
La suma debe igualar el total de la Invoice.
|
||||
"""
|
||||
series = store_config["sap"]["numbering_series"]["INCOMING_PAYMENT"]
|
||||
doc_date = invoice["DocDate"]
|
||||
|
||||
efectivas = transacciones_efectivas(transactions)
|
||||
if not efectivas:
|
||||
raise PaymentConfigError("La orden no tiene transacciones de pago exitosas")
|
||||
|
||||
tarjetas: List[Dict[str, Any]] = []
|
||||
transferencia = Decimal("0")
|
||||
cuentas_transfer = set()
|
||||
gateways: List[str] = []
|
||||
|
||||
for t in efectivas:
|
||||
gateway = t.get("gateway")
|
||||
metodo = _metodo(store_config, gateway)
|
||||
monto = Decimal(str(t["amount"]))
|
||||
gateways.append(gateway)
|
||||
|
||||
if metodo["type"] == "credit_card":
|
||||
if not metodo.get("credit_card"):
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' sin tarjeta de crédito (credit_card) asociada")
|
||||
linea = {
|
||||
"CreditCard": metodo["credit_card"],
|
||||
"CreditCardNumber": CARD_LAST_DIGITS,
|
||||
"CardValidUntil": CARD_VALID_UNTIL,
|
||||
"VoucherNum": str(order.get("name") or "")[:VOUCHER_MAX_LEN],
|
||||
"CreditSum": float(monto),
|
||||
"CreditCur": "CLP",
|
||||
"CreditType": "cr_Regular",
|
||||
"NumOfPayments": metodo.get("num_of_payments", 1),
|
||||
"FirstPaymentDue": doc_date,
|
||||
"SplitPayments": "tYES" if metodo.get("split_payment") else "tNO",
|
||||
}
|
||||
if metodo.get("payment_method_code"):
|
||||
linea["PaymentMethodCode"] = metodo["payment_method_code"]
|
||||
tarjetas.append(linea)
|
||||
elif metodo["type"] == "transfer":
|
||||
if not metodo.get("transfer_account"):
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' sin transfer_account")
|
||||
cuentas_transfer.add(metodo["transfer_account"])
|
||||
transferencia += monto
|
||||
else:
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' con type inválido: {metodo['type']!r}")
|
||||
|
||||
if len(cuentas_transfer) > 1:
|
||||
raise PaymentConfigError(f"Transferencias con cuentas distintas no soportado: {sorted(cuentas_transfer)}")
|
||||
|
||||
total_pagado = transferencia + sum((Decimal(str(l["CreditSum"])) for l in tarjetas), Decimal("0"))
|
||||
if total_pagado != Decimal(str(invoice_total)):
|
||||
raise PaymentConfigError(f"Suma de pagos ({total_pagado}) no coincide con el total de la Invoice ({invoice_total})")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"CardCode": invoice["CardCode"],
|
||||
"CardName": invoice["CardName"],
|
||||
"DocType": "rCustomer",
|
||||
"DocDate": doc_date,
|
||||
"Series": series,
|
||||
"DocCurrency": "CLP",
|
||||
"Remarks": " + ".join(dict.fromkeys(gateways)),
|
||||
"PaymentInvoices": [
|
||||
{
|
||||
"DocEntry": invoice_doc_entry,
|
||||
"InvoiceType": "it_Invoice",
|
||||
"SumApplied": invoice_total,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if tarjetas:
|
||||
payload["PaymentCreditCards"] = tarjetas
|
||||
if transferencia:
|
||||
payload["TransferAccount"] = next(iter(cuentas_transfer))
|
||||
payload["TransferSum"] = float(transferencia)
|
||||
payload["TransferDate"] = doc_date
|
||||
payload["TransferReference"] = order.get("name")
|
||||
|
||||
return payload
|
||||
@@ -0,0 +1,47 @@
|
||||
def _limpiar(rut: str) -> str:
|
||||
return rut.upper().replace(".", "").replace("-", "").strip()
|
||||
|
||||
|
||||
def _calcular_dv(numero: str) -> str:
|
||||
suma = 0
|
||||
multiplicador = 2
|
||||
for digito in reversed(numero):
|
||||
suma += int(digito) * multiplicador
|
||||
multiplicador = multiplicador + 1 if multiplicador < 7 else 2
|
||||
|
||||
resto = 11 - (suma % 11)
|
||||
if resto == 11:
|
||||
return "0"
|
||||
if resto == 10:
|
||||
return "K"
|
||||
return str(resto)
|
||||
|
||||
|
||||
def validar_rut(rut: str) -> bool:
|
||||
"""
|
||||
Valida un RUT chileno usando el dígito verificador módulo 11.
|
||||
Acepta con o sin puntos/guión: "12345678-5", "12.345.678-5", "123456785".
|
||||
"""
|
||||
limpio = _limpiar(rut)
|
||||
if len(limpio) < 2:
|
||||
return False
|
||||
|
||||
numero, dv = limpio[:-1], limpio[-1]
|
||||
if not numero.isdigit():
|
||||
return False
|
||||
|
||||
return _calcular_dv(numero) == dv
|
||||
|
||||
|
||||
def normalizar_rut(rut: str) -> str:
|
||||
"""Devuelve el RUT sin puntos ni guión, en mayúsculas (ej. "123456785")."""
|
||||
return _limpiar(rut)
|
||||
|
||||
|
||||
def formatear_rut(rut: str) -> str:
|
||||
"""
|
||||
Devuelve el RUT en el formato que usa SAP para FederalTaxID:
|
||||
"NNNNNNNN-D" (sin puntos, con guión antes del dígito verificador).
|
||||
"""
|
||||
limpio = _limpiar(rut)
|
||||
return f"{limpio[:-1]}-{limpio[-1]}"
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
ITR_BASE_TYPE = 1250000001 # DocObjectCode de InventoryTransferRequests
|
||||
|
||||
|
||||
def construir_stock_transfer(itr: Dict[str, Any], store_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST StockTransfers a partir de una
|
||||
InventoryTransferRequest ya creada en SAP (itr = el documento completo
|
||||
leído de SAP, con LineNum reales). Copia CardCode/CardName/Comments/
|
||||
almacenes/UDFs de la ITR, y cada línea referencia su línea base vía
|
||||
BaseType/BaseEntry/BaseLine.
|
||||
"""
|
||||
document_lines = [
|
||||
{
|
||||
"ItemCode": line["ItemCode"],
|
||||
"Quantity": line["Quantity"],
|
||||
"FromWarehouseCode": line["FromWarehouseCode"],
|
||||
"WarehouseCode": line["WarehouseCode"],
|
||||
"BaseType": ITR_BASE_TYPE,
|
||||
"BaseEntry": itr["DocEntry"],
|
||||
"BaseLine": line["LineNum"],
|
||||
}
|
||||
for line in itr["StockTransferLines"]
|
||||
]
|
||||
|
||||
return {
|
||||
"CardCode": itr.get("CardCode"),
|
||||
"CardName": itr.get("CardName"),
|
||||
"Comments": itr.get("Comments"),
|
||||
"DocDate": itr["DocDate"][:10],
|
||||
"FromWarehouse": itr["FromWarehouse"],
|
||||
"ToWarehouse": itr["ToWarehouse"],
|
||||
"Series": store_config["sap"]["numbering_series"]["STOCK_TRANSFER"],
|
||||
"U_ShopifyOrderId": itr.get("U_ShopifyOrderId"),
|
||||
"U_ShopifyStore": itr.get("U_ShopifyStore"),
|
||||
"StockTransferLines": document_lines,
|
||||
}
|
||||
Reference in new issue
Block a user