2026-09-30 19:22:40 -05:00
# 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)
```mermaid
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`).
` ``mermaid
%%{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` |
` ``mermaid
%%{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.
2026-09-30 19:49:49 -05:00
- **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.
2026-09-30 19:22:40 -05:00
- **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)`.