Files
georgeandClaude Sonnet 5.5 38b152d6b5 SAP: una sesión por conexión, reutilizada y renovada solo ante un 401
Antes se abría un login por orden (un cliente por orden, con la sesión
guardada solo en la instancia) y nunca se cerraba. Ahora el B1SESSION se
guarda por conexión (URL + base + usuario) en un caché del proceso, se
renueva solo si SAP responde 401 y se cierra con Logout al terminar el
proceso (SAPServiceLayerClient.cerrar_sesiones).

Probado contra SAP: 6 clientes de ambas tiendas hacen 1 login; una sesión
inválida provoca 1 login tras el 401; la integración de 3 órdenes con SAP
bloqueado para escrituras hace 1 login.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 19:49:49 -05:00

13 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):

  1. descargar_cabeceras_nuevas: baja cabeceras nuevas desde Shopify (since_id = último id guardado para esa tienda) y las guarda en orders con status = 'pending'.
  2. obtener_ordenes_pendientes: marca como timeout las vencidas (ver Caso F) y devuelve las que no llegaron a un estado terminal, ordenadas por time_update ASC (rotación justa — ver nota al final).
  3. Por cada orden pendiente (hasta max_orders_per_cycle), se llama a analizar_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:

  1. Buscar RUT (buscar_rut_en_order): solo en note_attributes de la orden (clave que contenga "rut", validado con módulo 11). No se consultan los metafields del customer.
  2. Sin RUT válido → se usa el CardCode genérico de la tienda (sap.generic_card_code).
  3. Con RUT válido → se busca BusinessPartner en SAP por FederalTaxID (formato NNNNNNNN-D):
    • Si existe → se usa ese CardCode.
    • Si no existe → se crea un BusinessPartner nuevo (CardCode = "C" + RUT, CardType: cCustomer, GroupCode: 100, con BPAddresses Bill-To/Ship-To). Si la creación falla, se cae al genérico.
  4. En cualquier caso donde haya RUT válido, se guarda también en formato SAP (rut_sap) para sobrescribir FederalTaxID en la Invoice — así el RUT real del cliente queda en el documento aunque se use el CardCode gené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:

  1. Vuelve a descargar el detalle completo de la orden.
  2. Recorre todos los fulfillments que trae Shopify en ese momento.
  3. Para cada uno, ensure_order_fulfillment/ensure_order_document crean la fila solo si no existe — los fulfillments/documentos ya procesados en corridas anteriores se detectan (status == "created") y se saltan (se hace GET en vez de POST), nunca se duplican.
  4. 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:

  1. mark_document_error marca esa fila de order_documents como error (guarda el mensaje) y orders.status = "error" con ese mismo mensaje.
  2. Antes de relanzar el error, domain/documents.py::diagnosticar_error intenta 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.
  3. La orden sigue apareciendo en obtener_ordenes_pendientes (no es un estado terminal) y se reintenta en una corrida futura.
  4. Al reintentar, cada documento que ya estaba created se reutiliza (se lee de SAP, no se vuelve a crear) — solo se reintenta lo que falló.
  5. Los errores quedan disponibles para revisión en core/notifications.py::generar_reporte_errores_html() (HTML guardado en notifications/, 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_at no vacío → cancelled (terminal, nunca se integra). Se integran las órdenes paid, partially_refunded y refunded con fulfillments; el reembolso es un tratamiento interno de comercial y no se refleja en SAP. pending/voided y las órdenes sin despachar siguen como not_eligible.
  • Montos: la Invoice iguala lo realmente cobrado (transacciones success de tipo sale/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.
  • Sesión de SAP: el B1SESSION se guarda por conexión (URL + base + usuario) y se reutiliza entre órdenes y ciclos; solo se vuelve a hacer Login si SAP responde 401, y se hace Logout al terminar el proceso (SAPServiceLayerClient.cerrar_sesiones()). Antes se abría una sesión por orden y nunca se cerraba.
  • Red: ShopifyClient reintenta hasta 4 veces ante fallos de red (SSL, conexión, timeout), con espera creciente.
  • Cursor: max(id máximo en BD, initial_since_id).