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>
12 KiB
Flujo del pipeline Shopify → SAP Business One
Este documento describe, caso por caso, cómo analizar_y_procesar_orden
(pipeline/runner.py) procesa una orden. El pipeline evalúa condiciones en
orden y cada orden termina en uno de estos casos según cómo venga desde
Shopify.
Punto de entrada
Cada ciclo (ejecutar_ciclo, disparado por el scheduler en main.py):
descargar_cabeceras_nuevas: baja cabeceras nuevas desde Shopify (since_id= último id guardado para esa tienda) y las guarda enordersconstatus = 'pending'.obtener_ordenes_pendientes: marca comotimeoutlas vencidas (ver Caso F) y devuelve las que no llegaron a un estado terminal, ordenadas portime_update ASC(rotación justa — ver nota al final).- Por cada orden pendiente (hasta
max_orders_per_cycle), se llama aanalizar_y_procesar_orden.
Diagrama general (color por caso)
flowchart TD
Cycle["Inicio del ciclo:\nmark_orders_timeout\n(sobre TODAS las pendientes)"] -->|vencidas| CaseG["Caso G\ntimeout"]
Cycle -->|el resto| Start(["Por cada orden pendiente\n(analizar_y_procesar_orden)"])
Start --> ChkPaid{"¿paid AND\ntiene fulfillments?"}
ChkPaid -->|No| CaseA["Caso A\nnot_eligible"]
ChkPaid -->|Sí| ChkShip{"¿costo de\nenvío > 0?"}
ChkShip -->|Sí| CaseB["Caso B\nerror: envío\nno soportado"]
ChkShip -->|No| CaseC["Caso C\nResolver RUT\ny CardCode"]
CaseC --> DocInv["POST Invoices\n+ POST IncomingPayments"]
DocInv -->|error| CaseH["Caso H\nerror, se\nreintenta luego"]
DocInv -->|ok| LoopF{"¿Quedan\nfulfillments por\nprocesar?"}
LoopF -->|No| ChkMore{"¿fulfillment_status\n== fulfilled?"}
LoopF -->|Sí| ChkStore{"¿location_id\nes tienda?"}
ChkStore -->|Sí| CaseD["Caso D\nITR + StockTransfer\n+ Delivery"]
ChkStore -->|No| CaseE["Caso E\nSolo ITR"]
CaseD -->|ok, siguiente| LoopF
CaseE -->|ok, siguiente| LoopF
CaseD -->|error| CaseH
CaseE -->|error| CaseH
ChkMore -->|No, esperando más| CaseF["Caso F\nin_progress"]
ChkMore -->|Sí| Done["integrated"]
classDef caseA fill:#fde68a,stroke:#92400e,color:#000
classDef caseB fill:#fca5a5,stroke:#991b1b,color:#000
classDef caseC fill:#bfdbfe,stroke:#1e3a8a,color:#000
classDef caseD fill:#86efac,stroke:#14532d,color:#000
classDef caseE fill:#5eead4,stroke:#134e4a,color:#000
classDef caseF fill:#d8b4fe,stroke:#581c87,color:#000
classDef caseG fill:#d1d5db,stroke:#1f2937,color:#000
classDef caseH fill:#fdba74,stroke:#7c2d12,color:#000
classDef terminal fill:#4ade80,stroke:#14532d,color:#000
class CaseA caseA
class CaseB caseB
class CaseC caseC
class CaseD caseD
class CaseE caseE
class CaseF caseF
class CaseG caseG
class CaseH caseH
class Done terminal
Nota: LoopF representa el for fulfillment in detalle["fulfillments"] del código — se
evalúa una vez por cada fulfillment de la orden, y ChkMore (¿ya no quedan?) se
resuelve recién cuando termina de recorrerlos todos.
Caso A — Orden no elegible
Filtro: financial_status != "paid" o fulfillments vacío.
Resultado: orders.status = "not_eligible". No se crea ningún documento
en SAP. La orden se vuelve a revisar en el próximo ciclo (por si cambia de
estado en Shopify), hasta que cumpla la condición o llegue al timeout
(Caso F).
Caso B — Costo de envío no soportado
Filtro: total_shipping_price_set.shop_money.amount > 0.
Resultado: orders.status = "error", con last_error explicando que el
mapeo de envío aún no está soportado. No se crea ningún documento. Queda
pendiente definir cómo reflejar el flete en el Invoice.
Caso C — Resolución de cliente (sub-flujo, aplica a todos los casos D/E)
Antes de crear la Invoice, se resuelve el CardCode:
- Buscar RUT (
buscar_rut_en_order): solo ennote_attributesde la orden (clave que contenga "rut", validado con módulo 11). No se consultan los metafields del customer. - Sin RUT válido → se usa el
CardCodegenérico de la tienda (sap.generic_card_code). - Con RUT válido → se busca
BusinessPartneren SAP porFederalTaxID(formatoNNNNNNNN-D):- Si existe → se usa ese
CardCode. - Si no existe → se crea un
BusinessPartnernuevo (CardCode = "C" + RUT,CardType: cCustomer,GroupCode: 100, conBPAddressesBill-To/Ship-To). Si la creación falla, se cae al genérico.
- Si existe → se usa ese
- En cualquier caso donde haya RUT válido, se guarda también en formato SAP
(
rut_sap) para sobrescribirFederalTaxIDen la Invoice — así el RUT real del cliente queda en el documento aunque se use elCardCodegenérico.
El CardCode resuelto se guarda en orders.card_code y no se vuelve a
resolver en corridas posteriores de la misma orden.
Caso D — Fulfillment por tienda física (is_store: true)
Filtro: el location_id del fulfillment está mapeado en
config/stores/<tienda>.json → sap.locations con is_store: true.
Secuencia de documentos (una Invoice/Payment por orden, el resto por cada fulfillment):
| Orden | Documento | Base de datos previa | Notas |
|---|---|---|---|
| 1 | Invoices (ReserveInvoice: tYES) |
— | CardCode/CardName/FederalTaxID/direcciones sobrescritas con datos reales del cliente. Bodega = default_warehouse_invoice. |
| 2 | IncomingPayments |
Invoice | Aplicado a la Invoice (PaymentInvoices). TransferAccount fijo, Remarks = medio(s) de pago de Shopify. |
| 3 | InventoryTransferRequests |
— | Una por fulfillment. Bodega origen = la del location_id (bodega real); destino = default_warehouse_invoice (virtual). |
| 4 | StockTransfers |
ITR (BaseType/BaseEntry/BaseLine) |
Mismo sentido que la ITR que satisface. |
| 5 | DeliveryNotes |
Invoice (BaseType/BaseEntry/BaseLine) |
Solo entrega los ítems de ese fulfillment (line items del StockTransfer, no toda la orden). |
Cada documento creado a partir de la Invoice (ITR, StockTransfer, Delivery)
se enlaza de vuelta a ella vía DocumentReferences (PATCH Invoices).
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#86efac', 'primaryBorderColor': '#14532d', 'primaryTextColor': '#000', 'actorBkg': '#86efac', 'actorBorder': '#14532d', 'signalColor': '#14532d', 'signalTextColor': '#000'}}}%%
sequenceDiagram
participant P as Pipeline
participant S as Shopify
participant SAP as SAP Service Layer
P->>S: GET orders/{id}.json
opt hay RUT válido (note_attributes)
P->>SAP: GET BusinessPartners (por RUT)
opt no existe el BP
P->>SAP: POST BusinessPartners
end
end
P->>SAP: POST Invoices
P->>SAP: POST IncomingPayments
loop por cada fulfillment de tienda
P->>SAP: POST InventoryTransferRequests
P->>SAP: GET Invoices / PATCH Invoices (ref. ITR)
P->>SAP: GET InventoryTransferRequests (líneas)
P->>SAP: POST StockTransfers
P->>SAP: GET Invoices / PATCH Invoices (ref. StockTransfer)
P->>SAP: GET StockTransfers (líneas)
P->>SAP: POST DeliveryNotes
P->>SAP: GET Invoices / PATCH Invoices (ref. Delivery)
end
Resultado final: si todos los documentos de todos los fulfillments se
crean bien y fulfillment_status == "fulfilled" → status = "integrated".
Si falta algún fulfillment por llegar → status = "in_progress" (ver Caso
E). Si algo falla → status = "error" (ver Caso G).
Caso E — Fulfillment por centro de distribución (is_store: false)
Mismo flujo que el Caso D, pero se detiene después de la ITR: no se
crea StockTransfer ni DeliveryNotes para ese fulfillment.
| Orden | Documento |
|---|---|
| 1 | Invoices |
| 2 | IncomingPayments |
| 3 | InventoryTransferRequests |
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#5eead4', 'primaryBorderColor': '#134e4a', 'primaryTextColor': '#000', 'actorBkg': '#5eead4', 'actorBorder': '#134e4a', 'signalColor': '#134e4a', 'signalTextColor': '#000'}}}%%
sequenceDiagram
participant P as Pipeline
participant SAP as SAP Service Layer
P->>SAP: POST Invoices
P->>SAP: POST IncomingPayments
loop por cada fulfillment de CD
P->>SAP: POST InventoryTransferRequests
P->>SAP: GET Invoices / PATCH Invoices (ref. ITR)
end
Caso F — Multi-fulfillment / envío parcial
Una orden puede tener varios fulfillments a lo largo del tiempo (envío
parcial). Cada corrida:
- Vuelve a descargar el detalle completo de la orden.
- Recorre todos los fulfillments que trae Shopify en ese momento.
- Para cada uno,
ensure_order_fulfillment/ensure_order_documentcrean la fila solo si no existe — los fulfillments/documentos ya procesados en corridas anteriores se detectan (status == "created") y se saltan (se haceGETen vez dePOST), nunca se duplican. - Si aparece un fulfillment nuevo que antes no estaba, se procesa como
Caso D o E según su
location_id, sin tocar lo ya integrado.
Resultado: la orden queda en in_progress mientras
fulfillment_status != "fulfilled", y pasa a integrated recién cuando
Shopify indica que ya no se esperan más fulfillments.
Caso G — Timeout
Filtro (mark_orders_timeout, se corre al inicio de cada ciclo): la
orden no llegó a integrated, y pasaron más de timeout_days (config por
tienda) desde shopify_created_at.
Resultado: orders.status = "timeout" (estado terminal, no se vuelve a
procesar).
Caso H — Error y reintento
Cualquier documento puede fallar (SAP rechaza el POST). Al fallar:
mark_document_errormarca esa fila deorder_documentscomoerror(guarda el mensaje) yorders.status = "error"con ese mismo mensaje.- Antes de relanzar el error,
domain/documents.py::diagnosticar_errorintenta reconocer la causa real y devolver un mensaje claro:- Tipo de cambio faltante: "No hay tipo de cambio cargado en SAP para la moneda X".
- ItemCode inexistente: revisa cada SKU contra SAP y dice cuál(es) no existen, en vez del mensaje genérico de SAP.
- Cualquier otro error: se deja el mensaje original de SAP tal cual.
- La orden sigue apareciendo en
obtener_ordenes_pendientes(no es un estado terminal) y se reintenta en una corrida futura. - Al reintentar, cada documento que ya estaba
createdse reutiliza (se lee de SAP, no se vuelve a crear) — solo se reintenta lo que falló. - Los errores quedan disponibles para revisión en
core/notifications.py::generar_reporte_errores_html()(HTML guardado ennotifications/, el envío por mail queda pendiente para más adelante).
Nota: rotación justa entre reintentos
obtener_ordenes_pendientes ordena por time_update ASC: la orden que
hace más tiempo no se toca va primero. Cada vez que se procesa una orden
(éxito o error) se actualiza su time_update, así que pasa al final de la
fila. Esto evita que una orden que falla repetido monopolice el cupo de
max_orders_per_cycle y bloquee a las órdenes nuevas.
Reglas vigentes que amplían este flujo
Este documento describe el flujo base. Las reglas siguientes lo complementan (detalle y órdenes de ejemplo en
docs/casos_especiales.md):
- Elegibilidad:
cancelled_atno vacío →cancelled(terminal, nunca se integra). Se integran las órdenespaid,partially_refundedyrefundedcon fulfillments; el reembolso es un tratamiento interno de comercial y no se refleja en SAP.pending/voidedy las órdenes sin despachar siguen comonot_eligible. - Montos: la Invoice iguala lo realmente cobrado (transacciones
successde tiposale/capture); se elige entre líneas originales y vigentes, y los fulfillments se limitan a lo facturado. - Pago: por transacción efectiva, con la tarjeta o cuenta de
sap.payment_methods. - Idempotencia contra SAP: antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento ya
existente para la orden (
U_ShopifyOrderId+U_ShopifyStore, y mismas líneas); si existe se adopta y no se duplica. Una Invoice ya pagada en SAP no genera otro pago. - Red:
ShopifyClientreintenta hasta 4 veces ante fallos de red (SSL, conexión, timeout), con espera creciente. - Cursor:
max(id máximo en BD, initial_since_id).