# 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/.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. - **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)`.