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:
georgeandClaude Sonnet 5.5 committed 2026-09-30 19:22:40 -05:00
commit 901dd8763d
47 files changed
+4709

No files matched your search

+281
View File
@@ -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)`.