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,281 @@
|
||||
# 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)`.
|
||||
Reference in new issue
Block a user