281 lines
12 KiB
Markdown
281 lines
12 KiB
Markdown
# 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.
|
||
|
|
- **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)`.
|