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

+84
View File
@@ -0,0 +1,84 @@
# Análisis de tiempos del pipeline Shopify → SAP
Fecha de medición: 2026-09-25. Entorno: SAP Service Layer (`SBODemoCL`) en la misma
red/VPN, Shopify Admin API por internet.
## Latencias individuales medidas (llamadas aisladas)
| Llamada | Tiempo promedio |
|---|---|
| SAP GET (misma red/VPN) | ~0.056 s |
| Shopify GET orden completa | ~0.469 s |
## Ciclo real completo (20 órdenes, `snobbycl`, sin RUT)
| Métrica | Valor |
|---|---|
| Duración total | 87 s |
| Promedio por orden | ~4.3 s |
| Intervalo configurado ("rapido") | 600 s |
| Margen usado | ~14% del ciclo |
Esta medición corresponde a órdenes **sin RUT** (ninguna llamó a `BusinessPartners`),
caso "tienda" completo: Invoice → Payment → InventoryTransferRequest → StockTransfer
→ Delivery, con sus referencias cruzadas.
## Ruta más larga: tienda + RUT nuevo (creación de BusinessPartner)
16 llamadas en total:
| # | Servicio | Llamada | Tiempo aprox. |
|---|---|---|---|
| 1 | Shopify | GET `orders/{id}.json` | ~0.47 s |
| 2 | SAP | GET `BusinessPartners` (buscar por RUT) | ~0.06 s |
| 3 | SAP | POST `BusinessPartners` (crear, RUT no existía) | ~0.40 s |
| 4 | SAP | POST `Invoices` | ~0.35 s |
| 5 | SAP | POST `IncomingPayments` | ~0.35 s |
| 6 | SAP | POST `InventoryTransferRequests` | ~0.35 s |
| 7 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
| 8 | SAP | PATCH `Invoices` (agregar ref. ITR) | ~0.35 s |
| 9 | SAP | GET `InventoryTransferRequests` (traer líneas completas) | ~0.06 s |
| 10 | SAP | POST `StockTransfers` | ~0.35 s |
| 11 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
| 12 | SAP | PATCH `Invoices` (agregar ref. StockTransfer) | ~0.35 s |
| 13 | SAP | GET `StockTransfers` (traer líneas completas) | ~0.06 s |
| 14 | SAP | POST `DeliveryNotes` | ~0.35 s |
| 15 | SAP | GET `Invoices` (leer referencias) | ~0.06 s |
| 16 | SAP | PATCH `Invoices` (agregar ref. Delivery) | ~0.35 s |
### Subtotales
| Tipo | Cantidad | Tiempo total |
|---|---|---|
| Shopify GET | 1 | ~0.47 s |
| SAP GET | 6 | ~0.36 s |
| SAP POST | 6 | ~2.15 s |
| SAP PATCH | 3 | ~1.05 s |
| **Total** | **16** | **~4.0-4.6 s** |
Coincide con lo medido en vivo (~4.3 s/orden en `snobbycl` sin RUT — a esa ruta le
faltan las llamadas 2 y 3 de la tabla, por eso da un poco menos que esta ruta
completa con creación de BusinessPartner).
Caso CD (solo ITR, sin StockTransfer ni Delivery, sin RUT): ~5 llamadas, bastante
más rápido que el caso tienda completo.
## Capacidad estimada por ciclo
Con ~4.3-4.6 s/orden real, dejando colchón de seguridad (~70-80% del intervalo
usado, el resto para variabilidad de red, reintentos por 401, rate limit de
Shopify):
| Modo | Intervalo | Teórico máx. | Recomendado con margen |
|---|---|---|---|
| rapido | 600 s | ~139 órdenes | ~95-110 |
| full | 1800 s | ~418 órdenes | ~290-330 |
## Configuración aplicada
`max_orders_per_cycle = 50` para ambas tiendas (`snobbycl` y
`on-chile-distributor-prod`), muy por debajo de la capacidad real medida —
deja margen amplio para picos de tráfico o llamadas más lentas de lo normal.
Con 50 órdenes/ciclo y ~4.6 s/orden en el peor caso (todas con RUT nuevo),
un ciclo tardaría ~230 s, menos del 40% del intervalo "rapido" de 600 s.
+103
View File
@@ -0,0 +1,103 @@
# Casos especiales de integración (Shopify → SAP)
Casos que se salen del flujo normal (`docs/flujo_pipeline.md`): cómo se detectan, qué hace el pipeline y **una orden
real de ejemplo por tienda**. "sin caso" significa que no se encontró un ejemplo real en esa tienda (no que no pueda ocurrir).
Los JSON de Shopify de la mayoría de los ejemplos están en `download/` (`shopify_#<name>.json` = orden,
`shopify_transactions_#<name>.json` = transacciones).
Tiendas: **snobbycl** (órdenes `#NNNN`) y **on-chile-distributor-prod** (órdenes `#CLNNNN`).
## Regla general de montos
La **Invoice siempre iguala lo realmente cobrado** en Shopify (suma de las transacciones efectivas).
Las líneas se arman en `domain/invoice.py::_lineas_por_cobrado`:
1. Si el total de las líneas originales (`quantity`) coincide con lo cobrado → se usan esas.
2. Si no, y el total de las líneas vigentes (`current_quantity`) coincide → se usan esas.
3. Si ninguna coincide → se usan las originales y el control de suma del pago deja la orden en `error`
(no se crea el pago).
Transacciones efectivas (`domain/payment.py::transacciones_efectivas`): solo `status = success` y `kind`
`sale` o `capture`. Si una `authorization` tiene su `capture` (`parent_id`), cuenta solo la `capture`.
Se ignoran `failure`, `pending`, `refund` y `void`.
---
## A. Pagos
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|---|---|---|---|---|---|
| A1 | Venta normal | una `sale` exitosa = total de la orden | Invoice + pago + ITR (+ ST + Delivery si es tienda) | #8064 | #CL1467 |
| A2 | Intentos fallidos o pendientes antes del pago exitoso | varias transacciones, solo una `success`. `payment_gateway_names` lista todos los intentos y **no** indica quién pagó | solo la exitosa suma | #7880 (Transbank `failure` → Fintoc ok), #7903 (Mercado Pago `pending` → Transbank ok), #8063 (4 intentos, uno con status `error`, y 1 éxito) | #CL9958, #CL9994, #CL10041 (36 en septiembre) |
| A3 | `authorization` + `capture` (reversso) | dos `success`, la `capture` con `parent_id` = la `authorization` | solo cuenta la `capture`; reversso va como transferencia (`1-1-010-20-000`) | #7902 | #CL9948, #CL9950, #CL9969 (46 en septiembre) |
| A4 | Pago dividido real | dos `sale` `success` de gateways distintos | una línea de `PaymentCreditCards` por transacción, cada una con su tarjeta | sin caso | #CL10236 (Fintoc 152.991 + Transbank 54.000), #CL10594, #CL10967; #CL10947 (Transbank + Transbank Webpay, misma tarjeta SAP 4 → dos líneas) |
| A5 | Gateway con nombre distinto al configurado | el `gateway` no coincide con la clave de `sap.payment_methods` (comparación exacta) | agregar las variantes al config con la misma tarjeta | sin caso (sus nombres coinciden) | `Venti` (#CL9933, #CL10054) y `Transbank` (#CL10236), equivalentes a `VentiPay` y `Transbank Webpay` |
| A6 | Gateway sin configurar | no está en `sap.payment_methods`, o sin `credit_card`, o `type` inválido | `PaymentConfigError`: el documento de pago queda en `error` con el mensaje y **no se crea el pago** (la Invoice ya existe y se retoma después) | prueba sintética con un gateway inexistente | prueba sintética |
| A7 | `VoucherNum` | SAP rechaza más de 20 caracteres (`Value too long in property 'VoucherNum'`) | `VoucherNum` = `name` de la orden (máx. 20) | — | #CL1467 (falló con el `payment_id` de 25 caracteres) |
Datos fijos de las tarjetas (`domain/payment.py`, provisorios): últimos dígitos `1234`, vencimiento `2030-12-31`,
`CreditType cr_Regular`; `SplitPayments` y `NumOfPayments` según el config de cada medio.
## B. Líneas canceladas, devueltas o reembolsadas
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|---|---|---|---|---|---|
| B1 | Línea cancelada **antes** del pago | `refund_line_items` con `restock_type = cancel`; cobrado = total neto (`current_total_price`) | la Invoice usa las líneas vigentes | #7998 (4 unidades, cobrado 339.980; Invoice original DocNum 460 anulada y reintegrada como DocNum 516) | no detectado en septiembre |
| B2 | Línea cancelada con dinero reembolsado **después** de cobrar el total | `cancel` + transacción `refund` exitosa; cobrado = total original | la Invoice usa las líneas **originales** (el reembolso es un tratamiento interno de comercial) | sin caso | #CL10072 (cobrado 305.973, reembolsado 71.991) |
| B3 | Línea devuelta (`return`) **sin cobro** | `restock_type = return`, sin transacción `refund`, cobrado = total vigente | la Invoice usa las líneas vigentes y `fulfillments_facturados` limita los fulfillments a lo facturado: no se hace ITR, ST ni Delivery de la unidad devuelta (el Delivery fallaría con `KeyError` al no existir la línea base) | sin caso | 15 órdenes, entre ellas #CL10144, #CL10764, #CL10903 |
| B4 | Despachada, pagada y **luego reembolsada** (total o parcial) | `financial_status` `refunded` o `partially_refunded`, con fulfillments y sin `cancelled_at` | se integra completa como venta; el reembolso no se refleja en SAP (`ESTADOS_FINANCIEROS_FACTURABLES`) | #7954, #7963, #7978, #7983, #7987, #8020, #8029 (parcial) | #CL9947, #CL9968, #CL9994, #CL9998, #CL10039, #CL10047 (23 en septiembre, aún sin integrar) |
| B5 | Reembolso de dinero pendiente, sin líneas | `refund` en `pending` y `refunds` sin `refund_line_items`, total vigente = cobrado | no afecta Invoice ni pago: se integra normal | #8009 | sin caso |
| B6 | Pedido de prueba | decisión manual | `status = ignored` (terminal) | #8019 | sin caso |
## C. Estado de la orden en Shopify
| # | Caso | Cómo se detecta | Tratamiento | snobbycl | on-chile |
|---|---|---|---|---|---|
| C1 | Orden **anulada** | `cancelled_at` no vacío, aunque siga `paid` | `status = cancelled` (terminal) con fecha y `cancel_reason` en `last_error`; evento `order_cancelled`; nunca se crean documentos | #7893, #7985, #8003, #7862 (14 en total) | #CL9983, #CL10015, #CL10161 (22 en septiembre) |
| C2 | Pago pendiente o no pagada | `financial_status` fuera de `paid`/`partially_refunded`/`refunded` (ej. `pending`, `voided`) | `not_eligible`; se reevalúa en cada ciclo | sin caso vigente (#8010 y #8021 eran `voided` y están anuladas) | #CL10283, #CL10733, #CL10828 (`pending`) |
| C3 | Pagada y sin despachar | sin `fulfillments` | `not_eligible` hasta que se despache; al despacharse se integra sola | #8055 | #CL9951, #CL10006 (73 en septiembre) |
| C4 | Costo de envío mayor que cero | `total_shipping_price_set > 0` | `error`: el mapeo del envío no está soportado | sin caso | #CL10169, #CL1452, #CL1453 |
| C5 | Orden sin líneas vigentes | todas las líneas canceladas | `not_eligible` ("Orden sin líneas vigentes") | sin caso | sin caso |
Estados terminales: `integrated`, `timeout`, `ignored`, `cancelled` (ver `docs/esquema_base_datos.md`).
## D. SAP
| # | Caso | Síntoma | Tratamiento | snobbycl | on-chile |
|---|---|---|---|---|---|
| D1 | SKU inexistente | `POST Invoices` 400 `Item number is missing` (`-5009`) | crear los artículos (`tools/cargar_items_faltantes.py`, grupo 108); la orden se reintenta | #8064 (primer intento) | detectado antes de integrar: 874 artículos faltantes en las órdenes de septiembre |
| D2 | Sin stock en la bodega de origen | el ITR/transferencia es rechazado | entrada de mercancía (`tools/entrada_mercancia_ordenes.py`) | bodegas `8801`, `8802` | bodegas `8803`, `7702` |
| D3 | Anular una Invoice | `Cancellation is not possible; posting date exceeds limit of 10 days`. El **DocNum no es el DocEntry** | reintentar cuando SAP lo permita; anular siempre por DocEntry | #7998 (DocNum 460 = DocEntry 514) | — |
| D4 | Documento base cerrado | Delivery: `One of the base documents has already been closed` | `error` en cada ciclo hasta resolverlo en SAP | #7863 (antiguo) | — |
| D5 | Cliente con RUT | `note_attributes` con un RUT válido | se busca/crea el BusinessPartner `C<RUT>` (crea clientes reales en SAP) | sin caso (ninguna orden trae RUT) | #CL1467 → `C<RUT>` (ej. `C12345678-5`) (977 de 1.049 elegibles traen RUT) |
| D6 | Cliente sin RUT | sin RUT válido en `note_attributes` | cliente genérico `sap.generic_card_code` (`C99998`); no se consultan los metafields del customer | #8064 | #CL9948 |
## E. Datos, cursor y reprocesos
| # | Caso | Descripción | Tratamiento | snobbycl | on-chile |
|---|---|---|---|---|---|
| E1 | Cursor `since_id` | se descarga desde `max(id máximo en BD, initial_since_id)`; `initial_since_id` es un **piso**. Órdenes de prueba con ids altos en la BD hacían saltar el cursor | alinear la BD (insertar cabeceras) o borrar las de prueba | — | #CL10809 y #CL10851 saltaban el cursor a fines de septiembre |
| E2 | Orden integrada en SAP sin registro local | si se borra su fila y vuelve a descargarse, se integraría de nuevo | reinsertarla como `ignored`; además la idempotencia (E6) adopta los documentos existentes | — | #CL10809 (Invoice 319, pago 67, ITR 23) |
| E3 | Timeout | `timeout_days` (60) desde la creación en Shopify | `timeout` (terminal). Corte operativo de on-chile: 01/09/2026 (`initial_since_id` = 8103221690695) | — | órdenes de septiembre en adelante |
| E4 | API de Shopify caída | error 401/403/red | el error se propaga: el ciclo registra "Error procesando tienda" y reintenta en el siguiente. Ya no hay fallback con archivos manuales (se retiró; se usó mientras faltaba el scope `read_orders` de on-chile) | — | — |
| E5 | Casos para análisis | el pipeline **no** guarda solo los casos especiales | `tools/descargar_ordenes_especiales.py <store>` los baja a `download/` | 26 órdenes | 55 órdenes |
| E6 | Documento ya existente en SAP (idempotencia) | la BD local no registra un documento que sí existe en SAP (BD perdida, copiada o editada) | antes de crear Invoice, ITR, StockTransfer o Delivery se busca por `U_ShopifyOrderId` + `U_ShopifyStore` con las mismas líneas y se **adopta**; solo Invoices vigentes (`CancelStatus = csNo`); una Invoice ya pagada no genera otro pago; evento `documento_adoptado`. **Límite:** no hay respaldo para el pago si la Invoice no figura pagada | #8064, #7998 (adopta la Invoice 516, no la anulada 460) | #CL1467 |
| E7 | Corte de red | `SSLEOFError`, reset de conexión, timeout | `ShopifyClient` reintenta hasta 4 veces con espera creciente; los errores HTTP no se reintentan | — | corte SSL al paginar cabeceras |
## F. No cubierto todavía
- **Reembolso o anulación posterior a integrar:** `integrated` es terminal, por lo que SAP queda con la venta completa y
sin nota de crédito ni devolución. Decisión pendiente con contabilidad (`docs/pendientes.md`).
- **Fulfillments vs. Invoice:** `fulfillments_facturados` reparte las cantidades de la Invoice entre los fulfillments en
orden; si una misma línea se devolviera parcialmente entre varios fulfillments, el reparto sigue ese orden y no el real.
- **Transferencias a cuentas distintas:** un solo `TransferAccount` por pago; con dos cuentas el pago da error.
## Herramientas útiles
| Herramienta | Uso |
|---|---|
| `python -m tools.consultar resumen / descargadas / integradas / errores / orden --name "#8064"` | estado de la BD (solo lectura, hora local; `APP_TIMEZONE`) |
| `python -m tools.integrar_ordenes <store> <shopify_id> [--apply]` | dry-run o integración de órdenes puntuales |
| `python -m tools.probar_pago <store> <shopify_id>` | payload del pago sobre la Invoice real |
| `python -m tools.descargar_ordenes_especiales <store>` | guarda casos especiales en `download/` |
| `python -m tools.entrada_mercancia_ordenes <config> --warehouse ... --orders ...` | stock para los SKUs de órdenes puntuales |
+198
View File
@@ -0,0 +1,198 @@
# Guía de configuración de tienda
Cada tienda Shopify que se integra vive en su propio archivo
`config/stores/<store_code>.json`. `pipeline/runner.py::cargar_tiendas()`
lee **todos** los `.json` de esa carpeta (ignorando los que empiezan con
`_`, como `_example.json`) y corre el pipeline completo para cada uno en
cada ciclo. Agregar una tienda nueva es tan simple como agregar un archivo
más — no requiere tocar código.
El nombre del archivo no lo usa el código (el loader lee cualquier
`*.json`), pero por claridad debe coincidir con el `store_code` de adentro,
en minúsculas (ej. `snobbycl.json`, `on-chile-distributor-prod.json`).
## Plantilla
Usa `config/stores/_example.json` como punto de partida (no se carga en el
pipeline por el `_` inicial):
```json
{
"store_code": "TIENDA1",
"shopify": {
"api_id": "TODO",
"store": "TODO-nombre-de-la-tienda-myshopify",
"api_version": "2026-07",
"access_token": "${TIENDA1_SHOPIFY_ACCESS_TOKEN}",
"page_limit": 50,
"initial_since_id": "TODO-id-de-orden-a-partir-del-cual-empezar-a-integrar-la-primera-vez"
},
"sap_connection": {
"url": "https://TODO-servidor:50000/b1s/v1",
"company_db": "TODO-SBO_COMPANYDB",
"username": "TODO",
"password": "${TIENDA1_SAP_PASSWORD}",
"timeout": 15,
"verify_ssl": false
},
"sap": {
"default_warehouse_invoice": "TODO-bodega-virtual-de-esta-tienda",
"generic_card_code": "TODO-CardCode-generico-si-el-RUT-no-es-valido",
"numbering_series": {
"INVOICE": null,
"INCOMING_PAYMENT": null,
"INVENTORY_TRANSFER_REQUEST": null,
"STOCK_TRANSFER": null,
"DELIVERY": null
},
"locations": {
"TODO-location_id-de-shopify": { "is_store": true, "warehouse_code": "TODO-bodega-SAP-de-esa-tienda-fisica" },
"TODO-location_id-del-CD": { "is_store": false, "warehouse_code": "TODO-bodega-SAP-del-CD" }
}
},
"timeout_days": 30,
"max_orders_per_cycle": 50
}
```
---
## Campo por campo
### `store_code`
Identificador corto de la tienda. Se usa en `orders.store_code`, en los
logs, en `NumAtCard`/UDFs (`U_ShopifyStore`), y para diferenciar tiendas que
comparten la misma base SAP.
### `shopify`
| Campo | Descripción |
|---|---|
| `api_id` | Usuario para autenticación HTTP Basic contra la Admin API (junto con `access_token`) |
| `store` | Subdominio de `myshopify.com` (sin el `.myshopify.com`) |
| `api_version` | Versión de la Admin API, ej. `"2026-07"` |
| `access_token` | Token de acceso de la app privada/custom de esa tienda. **No se escribe en el JSON**: va como `${TIENDA_SHOPIFY_ACCESS_TOKEN}` y el valor real en `.env` (ver *Secretos*). **Requiere el scope `read_orders` aprobado** (si no, la descarga de cabeceras falla con 403 `"This action requires merchant approval"`) |
| `page_limit` | Tope de cabeceras por página al descargar (`GET orders.json?limit=...`). No limita cuántas se **procesan** por ciclo — eso lo controla `max_orders_per_cycle` |
| `initial_since_id` | **Piso** del cursor de descarga: cada ciclo baja desde `max(id máximo guardado en la BD, initial_since_id)`. Evita traer el historial completo y permite saltar órdenes anteriores aunque haya cabeceras más antiguas en la BD (en on-chile, `8103221690695` = último id del 31/08/2026). Las órdenes con id menor nunca se descargan por el ciclo normal; para cargarlas hay que insertar sus cabeceras a mano |
### `sap_connection`
Credenciales de SAP Service Layer para esta tienda. Cada tienda puede
apuntar a una base SAP distinta (`SAPServiceLayerClient` se instancia una
vez por tienda, con su propia sesión).
| Campo | Descripción |
|---|---|
| `url` | URL base de Service Layer, ej. `https://servidor:50000/b1s/v1` |
| `company_db` | Nombre de la base de datos SAP (`CompanyDB` del login) |
| `username` / `password` | Usuario de SAP (ej. `manager`). La contraseña va como `${TIENDA_SAP_PASSWORD}` y el valor real en `.env` |
| `timeout` | Timeout en segundos para las llamadas HTTP a SAP |
| `verify_ssl` | Si se valida el certificado TLS del servidor SAP (`false` es común con certificados autofirmados internos) |
### `sap`
| Campo | Descripción |
|---|---|
| `default_warehouse_invoice` | Bodega virtual fija de esta tienda, usada en **todas** las líneas del Invoice (no la bodega real de la tienda/CD) |
| `generic_card_code` | `CardCode` a usar cuando no hay RUT válido, o no se pudo crear el `BusinessPartner` |
| `numbering_series` | Serie SAP (`Series`) para cada tipo de documento: `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY`. Se consiguen desde SAP Business One → Administración → Definición de numeración de documentos |
| `payment_methods` | Mapeo del nombre **exacto** del gateway de Shopify (`transactions[].gateway`) a su forma de pago en SAP. Ver la sección *payment_methods* más abajo |
| `locations` | Mapeo `location_id` de Shopify → `{is_store, warehouse_code}`. Define si ese `location_id` es una tienda física (genera `StockTransfer` + `Delivery`) o un CD (solo `InventoryTransferRequest`), y a qué bodega SAP corresponde. El campo opcional `"name"` es solo referencia legible, no lo usa el código |
**Cómo obtener los `location_id`**: `GET
/admin/api/{version}/locations.json` contra esa tienda (hay un ejemplo
armado en `tools/shopify.http` → `obtenerLocations`).
### `timeout_days`
Días desde `shopify_created_at` antes de marcar una orden como `timeout`
(estado terminal, deja de reintentarse) si no llegó a `integrated`.
### `max_orders_per_cycle`
Tope de órdenes pendientes que se procesan por ciclo, para esta tienda. Ver
`docs/analisis_tiempos.md` para la estimación de capacidad — con el tiempo
real medido (~4-5 s/orden), el valor recomendado por defecto es `50`.
---
## Pasos para agregar una tienda nueva
1. Crear la app privada/custom en Shopify para esa tienda y conseguir
`api_id` + `access_token`. Confirmar que el scope `read_orders` esté
**aprobado** por el comerciante (si no, la descarga de cabeceras falla
con 403).
2. Conseguir los datos de conexión a la base SAP que corresponda
(`sap_connection`).
3. Consultar `GET locations.json` de esa tienda en Shopify y armar el mapeo
`sap.locations` (cuáles son tiendas físicas, cuáles CD, y su bodega SAP).
4. Confirmar/crear en SAP: la bodega virtual (`default_warehouse_invoice`),
el `CardCode` genérico, y las series de numeración de cada tipo de
documento.
5. Copiar `_example.json` a `config/stores/<store_code>.json` y completar
todos los campos.
6. Ubicar el `initial_since_id` (desde qué orden se quiere empezar a
integrar) y ponerlo en `shopify.initial_since_id`.
7. Confirmar que los UDFs `U_ShopifyOrderId` / `U_ShopifyStore` existan en
las tablas de SAP que va a usar el pipeline (`OINV`, ITR, StockTransfer,
DeliveryNotes) — si no, hay que crearlos en SAP antes de la primera
corrida real.
8. Correr un ciclo (`ejecutar_ciclo`) y revisar `notifications/` /
`logs/events.jsonl` por si aparece algún error de configuración.
## Nota sobre secretos
Cada archivo de tienda tiene credenciales reales (token de Shopify, usuario
y clave de SAP). El proyecto todavía no es un repositorio git — cuando se
inicialice, hay que agregar `config/stores/*.json` (excepto `_example.json`)
al `.gitignore` para no subir secretos por accidente.
---
## `payment_methods` (dentro de `sap`)
La clave es el nombre **exacto** del gateway tal como llega en `transactions[].gateway` de
Shopify (no el de `payment_gateway_names`), sensible a mayúsculas. Si un pago usa un gateway
que no está aquí, el pago **no se crea** (documento en `error`, con el mensaje).
```json
"payment_methods": {
"Transbank Webpay": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 },
"Transbank": { "type": "credit_card", "credit_card": 4, "split_payment": true, "num_of_payments": 1 },
"reversso": { "type": "transfer", "transfer_account": "1-1-010-20-000" }
}
```
| Campo | Descripción |
|---|---|
| `type` | `credit_card` (línea en `PaymentCreditCards`) o `transfer` (`TransferAccount`/`TransferSum`). Otro valor da error |
| `credit_card` | Código de la tarjeta en SAP (`CreditCards`); cada medio tiene la suya, con su cuenta contable. Obligatorio si `type = credit_card` |
| `split_payment` | `SplitPayments` de la línea de tarjeta (`tYES`/`tNO`) |
| `num_of_payments` | `NumOfPayments` (cuotas) |
| `transfer_account` | Cuenta contable de la transferencia. Obligatorio si `type = transfer`. Solo una cuenta distinta por pago |
| `payment_method_code` | Opcional: `PaymentMethodCode` de SAP para la línea de tarjeta (hoy no se usa) |
Un mismo medio puede aparecer con variantes de nombre (ej. `Venti` y `VentiPay`). Datos fijos de la línea
de tarjeta (en `domain/payment.py`): últimos dígitos `1234` y vencimiento `2030-12-31`, `VoucherNum` = `name` de la orden.
Para descubrir los nombres reales: `python -m tools.descargar_ordenes_especiales <store>` o revisar
`transactions.json` de una orden. Casos y ejemplos: `docs/casos_especiales.md`.
---
## Secretos (`.env`)
Los tokens y contraseñas **no se guardan en el JSON**. Se escriben como `${NOMBRE}` y el valor real va en el
archivo `.env` de la raíz (o en variables de entorno del equipo/servicio):
```
ON_CHILE_SHOPIFY_ACCESS_TOKEN=shpat_...
ON_CHILE_SAP_PASSWORD=...
```
- `core/config.py::cargar_config_tienda()` reemplaza cada `${NOMBRE}` al leer el JSON (desde el entorno o `.env`;
el entorno tiene prioridad). Si falta alguna variable, falla con un mensaje claro (`ConfigError`).
- Los valores escritos directamente en el JSON siguen funcionando.
- `.env` está en `.gitignore`; `.env.example` lista las variables necesarias. Una pareja
`<TIENDA>_SHOPIFY_ACCESS_TOKEN` / `<TIENDA>_SAP_PASSWORD` por tienda.
- Todas las herramientas de `tools/` leen la configuración con este mismo cargador.
+169
View File
@@ -0,0 +1,169 @@
# Esquema de base de datos (SQLite)
Archivo: `data/shopify_sap.db`. Definido y accedido desde `core/db.py`. Tres
tablas: `orders` (cabecera), `order_fulfillments` (un fulfillment de
Shopify) y `order_documents` (un documento SAP requerido/creado).
```mermaid
erDiagram
orders ||--o{ order_fulfillments : "tiene"
orders ||--o{ order_documents : "tiene"
order_fulfillments ||--o{ order_documents : "tiene (si aplica)"
orders {
int sys_id PK
string store_code
int shopify_order_id
string order_number
string tags
string payment_gateway_names
string shopify_created_at
string time_insert
string time_update
string financial_status
string fulfillment_status
string card_code
string status
string last_error
}
order_fulfillments {
int sys_id PK
int order_sys_id FK
int shopify_fulfillment_id
string location_id
bool is_store
string warehouse_code
string time_insert
}
order_documents {
int sys_id PK
int order_sys_id FK
int fulfillment_sys_id FK
string doc_type
int sequence
bool required
string status
int sap_doc_entry
int sap_doc_num
int base_doc_entry
string error_message
int attempts
string time_update
}
```
---
## `orders`
Una fila por orden de Shopify (`UNIQUE(store_code, shopify_order_id)`, así
el mismo id de Shopify puede repetirse entre tiendas distintas).
| Columna | Tipo | Descripción |
|---|---|---|
| `sys_id` | INTEGER PK | id interno, autoincremental |
| `store_code` | TEXT | qué tienda (`config/stores/<store_code>.json`) |
| `shopify_order_id` | INTEGER | `id` de Shopify |
| `order_number` | TEXT | campo `name` de Shopify (ej. `"#7863"`) |
| `tags` | TEXT | tal cual viene de Shopify, separado por coma |
| `payment_gateway_names` | TEXT | medio(s) de pago, unidos con `" + "` si hay más de uno |
| `shopify_created_at` | TEXT | fecha de creación real en Shopify — dispara el `timeout` |
| `time_insert` | TEXT | cuándo se descargó/insertó por primera vez (auditoría) |
| `time_update` | TEXT | última vez que se tocó esta orden — define el orden de la cola (ver `status`) |
| `financial_status` | TEXT | último valor conocido de Shopify (puede cambiar entre corridas) |
| `fulfillment_status` | TEXT | ídem — `null`/`partial`/`fulfilled` |
| `card_code` | TEXT | `CardCode` de SAP ya resuelto (se cachea, no se vuelve a resolver) |
| `status` | TEXT | ver tabla de estados abajo |
| `last_error` | TEXT | último mensaje de error (ya diagnosticado, no el crudo de SAP si se reconoció) |
### Valores de `status`
| Valor | Terminal | Significado |
|---|---|---|
| `pending` | No | Cabecera descargada, aún no analizada |
| `not_eligible` | No | No está `paid`, o no tiene `fulfillments` todavía |
| `in_progress` | No | Tiene al menos un fulfillment procesado, pero `fulfillment_status != "fulfilled"` (pueden llegar más) |
| `error` | No | El último intento falló en algún documento; se reintenta en la próxima rotación |
| `integrated` | **Sí** | Todos los documentos requeridos están `created` y `fulfillment_status == "fulfilled"` |
| `timeout` | **Sí** | Pasaron más de `timeout_days` desde `shopify_created_at` sin llegar a `integrated` |
| `cancelled` | **Sí** | La orden tiene `cancelled_at` en Shopify (anulada). El pipeline la marca solo, con la fecha y el `cancel_reason` en `last_error`; nunca crea documentos en SAP |
| `ignored` | **Sí** | Marcada a mano para que el pipeline no la procese nunca (ej. pedidos de prueba). No se asigna automáticamente |
Solo `integrated`, `timeout`, `ignored` y `cancelled` son terminales —
`get_pending_orders` y `mark_orders_timeout` excluyen esos cuatro, todo lo demás
se sigue reintentando.
---
## `order_fulfillments`
Una fila por `fulfillment` de Shopify (`UNIQUE(order_sys_id,
shopify_fulfillment_id)`). Una orden puede tener varios (envío parcial).
| Columna | Tipo | Descripción |
|---|---|---|
| `sys_id` | INTEGER PK | |
| `order_sys_id` | FK → `orders.sys_id` | |
| `shopify_fulfillment_id` | INTEGER | `id` del fulfillment en Shopify |
| `location_id` | TEXT | `location_id` de Shopify de ese fulfillment |
| `is_store` | INTEGER (bool) | snapshot de `config.sap.locations[location_id].is_store` al momento de procesarlo |
| `warehouse_code` | TEXT | bodega SAP resuelta para ese `location_id` |
| `time_insert` | TEXT | cuándo se detectó este fulfillment |
---
## `order_documents`
Una fila por documento SAP requerido (exista o no todavía). Documentos a
nivel orden (`INVOICE`, `INCOMING_PAYMENT`) tienen `fulfillment_sys_id =
NULL`; los de nivel fulfillment (`INVENTORY_TRANSFER_REQUEST`,
`STOCK_TRANSFER`, `DELIVERY`) lo tienen seteado.
| Columna | Tipo | Descripción |
|---|---|---|
| `sys_id` | INTEGER PK | |
| `order_sys_id` | FK → `orders.sys_id` | |
| `fulfillment_sys_id` | FK → `order_fulfillments.sys_id`, nullable | `NULL` para documentos de orden |
| `doc_type` | TEXT | `INVOICE`, `INCOMING_PAYMENT`, `INVENTORY_TRANSFER_REQUEST`, `STOCK_TRANSFER`, `DELIVERY` |
| `sequence` | INTEGER | orden de creación (10/20/30/40/50 — incrementos de 10 para insertar tipos nuevos sin reordenar) |
| `required` | INTEGER (bool) | si este documento aplica para esta orden/fulfillment |
| `status` | TEXT | `pending` / `created` / `error` |
| `sap_doc_entry` / `sap_doc_num` | INTEGER | identificadores reales en SAP, una vez creado |
| `base_doc_entry` | INTEGER | *(reservado, no usado todavía — el `BaseEntry` real se arma al vuelo en `domain/*` a partir del documento base)* |
| `error_message` | TEXT | mensaje ya diagnosticado (ver `domain/documents.py::diagnosticar_error`) |
| `attempts` | INTEGER | cuántas veces falló |
| `time_update` | TEXT | última vez que se tocó esta fila |
### Valores de `status`
| Valor | Significado |
|---|---|
| `pending` | Fila creada (el documento es requerido), todavía no se intentó |
| `created` | Ya existe en SAP (`sap_doc_entry`/`sap_doc_num` poblados) — en un reintento, se relee con `GET` en vez de volver a crear |
| `error` | Falló el último intento — se reintenta en la próxima corrida |
---
## Funciones de `core/db.py` por tabla
**`orders`**: `insert_order_header`, `get_last_shopify_order_id`,
`get_order_sys_id`, `mark_orders_timeout`, `get_pending_orders`,
`set_order_status`, `set_order_card_code`, `update_order_snapshot`.
**`order_fulfillments`**: `ensure_order_fulfillment` (insert-si-no-existe,
por `shopify_fulfillment_id`).
**`order_documents`**: `ensure_order_document` (insert-si-no-existe, por
`doc_type` + `fulfillment_sys_id`), `get_order_document`,
`mark_document_created`, `mark_document_error`.
---
## Nota: precisión de `time_update`
Todos los `UPDATE`/`DEFAULT` de timestamp usan
`strftime('%Y-%m-%d %H:%M:%f','now')` (milisegundos), no `datetime('now')`
(que solo tiene precisión de segundo). Esto es necesario para que la
rotación justa de `get_pending_orders` (`ORDER BY time_update ASC`)
funcione bien cuando el pipeline toca varias órdenes rápido dentro del
mismo segundo.
+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)`.
+235
View File
@@ -0,0 +1,235 @@
# Mapeo de campos Shopify → SAP Business One
Referencia de cómo se arma cada documento SAP a partir del JSON de una orden
de Shopify. Los constructores viven en `domain/*.py`.
## RUT del cliente (`domain/business_partner.py`)
| Prioridad | Fuente | Detalle |
|---|---|---|
| 1 | `order.note_attributes` | Se busca una entrada cuyo `name` contenga "rut" (sin distinguir mayúsculas). Se valida el `value` con el formato y el dígito verificador (módulo 11). |
| 2 (respaldo) | Metafields del `customer` (Shopify) | Solo si no apareció en `note_attributes`. Se recorre cada metafield buscando un `value` con formato de RUT válido. |
- `normalizar_rut`: quita puntos/guión, mayúsculas (ej. `"123456785"`) — uso
interno para validar.
- `formatear_rut`: formato que usa SAP, `"NNNNNNNN-D"` (ej. `"12345678-5"`)
— se usa en `FederalTaxID`, `CardCode` y el filtro de búsqueda en SAP.
## Resolución de CardCode (`resolver_card_code`)
| Caso | CardCode usado |
|---|---|
| Sin RUT válido | `sap.generic_card_code` (config de la tienda) |
| RUT válido, existe `BusinessPartner` con ese `FederalTaxID` | El `CardCode` de ese BP |
| RUT válido, no existe | Se **crea** un BP nuevo, `CardCode = "C" + rut_sap` |
| RUT válido, pero falla la creación del BP | Cae a `sap.generic_card_code` |
En los tres primeros casos con RUT válido, `rut_sap` se guarda igual (para
`FederalTaxID` del Invoice), aunque termine usándose el `CardCode` genérico.
---
## `BusinessPartners` (creación, `domain/business_partner.py`)
| Campo SAP | Origen Shopify | Nota |
|---|---|---|
| `CardCode` | — | `"C" + rut_sap` |
| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` |
| `CardType` | — | fijo `"cCustomer"` |
| `GroupCode` | — | fijo `100` ("Clientes", mismo grupo que el genérico) |
| `FederalTaxID` | — | `rut_sap` |
| `Phone1` | `billing_address.phone`, si no `customer.phone` | |
| `EmailAddress` | `order.email`, si no `order.contact_email` | |
### `BPAddresses` (dos entradas)
| Campo SAP | Origen Shopify | Nota |
|---|---|---|
| `AddressName` | — | `"BillTo"` / `"ShipTo"` |
| `AddressType` | — | `bo_BillTo` / `bo_ShipTo` |
| `Street` | `address1` | |
| `Block` | `address2` | |
| `City` | `city` | |
| `ZipCode` | `zip` | |
| `County` | `province` | texto libre, no se traduce a código de región |
| `Country` | `country_code` | solo si es `"CL"` |
| `TaxCode` | — | fijo `"IVA"`, solo en la dirección `bo_ShipTo` |
`bo_BillTo` sale de `order.billing_address`; `bo_ShipTo` de
`order.shipping_address` (si no hay, usa `billing_address`).
---
## `Invoices` (factura de reserva, `domain/invoice.py`)
| Campo SAP | Origen Shopify | Nota |
|---|---|---|
| `CardCode` | — | resuelto (ver arriba) |
| `CardName` | `billing_address` (nombre completo), si no `customer` | fallback `"Cliente Shopify"` |
| `DocDate` / `DocDueDate` | `created_at` (solo fecha) | mismo valor en ambos |
| `Series` | — | `sap.numbering_series.INVOICE` (config tienda) |
| `DocType` | — | fijo `"dDocument_Items"` |
| `DocCurrency` | — | fijo `"CLP"` |
| `ReserveInvoice` | — | fijo `"tYES"` (factura de reserva, no releva stock al crearse) |
| `NumAtCard` | `order.name` | ej. `"#7863"`, para ubicarla fácil |
| `FederalTaxID` | `rut_sap` | solo si hay RUT válido; sobrescribe el del CardCode genérico |
| `U_ShopifyOrderId` | `order.id` | UDF |
| `U_ShopifyStore` | — | `store_code` de la config |
### `AddressExtension` (prefijo `BillTo`/`ShipTo`)
| Campo SAP | Origen Shopify | Nota |
|---|---|---|
| `{p}Street` | `address1` | |
| `{p}Block` | `address2` | |
| `{p}Building` | `phone` | reutilizado para guardar el teléfono |
| `{p}City` | `city` | |
| `{p}ZipCode` | `zip` | |
| `{p}County` | `province` | |
| `{p}Country` | `country_code` | solo si es `"CL"` |
`BillTo*` sale de `billing_address`, `ShipTo*` de `shipping_address`. SAP
calcula `Address`/`Address2` (texto plano) solo a partir de estos campos —
no se arman a mano.
### `DocumentLines` (una por `line_item` con `sku`)
| Campo SAP | Origen Shopify | Nota |
|---|---|---|
| `ItemCode` | `sku` | |
| `Quantity` | `quantity` | |
| `WarehouseCode` | — | `sap.default_warehouse_invoice` (bodega virtual, fija por tienda) |
| `PriceAfterVAT` | `price` menos descuento, ver nota | precio unitario, IVA incluido |
| `TaxCode` | — | fijo `"IVA"` (hardcode, sin lista de precios) |
**Cálculo de `PriceAfterVAT`**: `item.price` es el precio unitario **antes**
de descuento. El descuento real está en `discount_allocations` (el campo
`total_discount` no siempre lo refleja). Fórmula:
```
PriceAfterVAT = (price × quantity − Σ discount_allocations.amount) / quantity
```
---
## `IncomingPayments` (`domain/payment.py`)
| Campo SAP | Origen | Nota |
|---|---|---|
| `CardCode` | `invoice.CardCode` | mismo cliente que la Invoice |
| `CardName` | `invoice.CardName` | |
| `DocType` | — | fijo `"rCustomer"` |
| `DocDate` | `invoice.DocDate` | misma fecha que la Invoice |
| `Series` | — | `sap.numbering_series.INCOMING_PAYMENT` |
| `DocCurrency` | — | fijo `"CLP"` |
| `PaymentCreditCards[]` | una línea por transacción efectiva con `type = credit_card` | `CreditCard` = `sap.payment_methods[gateway].credit_card`; `CreditCardNumber` fijo `"1234"`; `CardValidUntil` fijo `"2030-12-31"`; `VoucherNum` = `order.name` (máx. 20); `CreditSum` = `transaction.amount`; `CreditCur` `"CLP"`; `CreditType` `"cr_Regular"`; `NumOfPayments` y `SplitPayments` del config; `FirstPaymentDue` = `invoice.DocDate` |
| `TransferAccount` / `TransferSum` / `TransferDate` / `TransferReference` | solo si hay transacciones con `type = transfer` (reversso) | cuenta de `sap.payment_methods[gateway].transfer_account`; `TransferSum` = suma de esas transacciones; `TransferReference` = `order.name` |
| `Remarks` | gateways de las transacciones efectivas | join con `" + "`, sin repetir. **No** se usa `payment_gateway_names` (lista también los intentos fallidos) |
| `PaymentInvoices[0].DocEntry` | `invoice.DocEntry` | |
| `PaymentInvoices[0].InvoiceType` | — | fijo `"it_Invoice"` |
| `PaymentInvoices[0].SumApplied` | total de la Invoice | debe igualar la suma de las transacciones efectivas; si no, `error` y no se crea el pago. Ver `docs/casos_especiales.md` |
---
## `InventoryTransferRequests` (`domain/inventory_transfer.py`)
Una por cada `fulfillment` de la orden (tienda o CD).
| Campo SAP | Origen | Nota |
|---|---|---|
| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | |
| `Comments` | `order.name` | |
| `DocDate` / `DueDate` | `order.created_at` (solo fecha) | |
| `Series` | — | `sap.numbering_series.INVENTORY_TRANSFER_REQUEST` |
| `FromWarehouse` | `sap.locations[location_id].warehouse_code` | bodega real de ese `location_id` |
| `ToWarehouse` | — | `sap.default_warehouse_invoice` (misma bodega virtual del Invoice) |
| `U_ShopifyOrderId` / `U_ShopifyStore` | `order.id` / `store_code` | |
### `StockTransferLines` (una por `line_item` del fulfillment)
| Campo SAP | Origen | Nota |
|---|---|---|
| `ItemCode` | `sku` del line_item del fulfillment | no toda la orden, solo lo de ese fulfillment |
| `Quantity` | `quantity` | |
| `FromWarehouseCode` | igual a `FromWarehouse` | |
| `WarehouseCode` | igual a `ToWarehouse` | |
`location_id → {is_store, warehouse_code}` sale de
`config/stores/<tienda>.json → sap.locations`.
---
## `StockTransfers` (`domain/stock_transfer.py`, solo si `is_store: true`)
Se arma a partir de la **ITR ya creada en SAP** (se relee completa para
tener `LineNum`).
| Campo SAP | Origen | Nota |
|---|---|---|
| `CardCode` / `CardName` / `Comments` | copiados de la ITR | |
| `DocDate` | `itr.DocDate` | |
| `FromWarehouse` / `ToWarehouse` | copiados de la ITR | mismo sentido que la solicitud |
| `Series` | — | `sap.numbering_series.STOCK_TRANSFER` |
| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la ITR | |
### `StockTransferLines` (una por línea de la ITR)
| Campo SAP | Origen | Nota |
|---|---|---|
| `ItemCode` / `Quantity` / `FromWarehouseCode` / `WarehouseCode` | copiados de la línea de la ITR | |
| `BaseType` | — | `1250000001` (`DocObjectCode` de `InventoryTransferRequests`) |
| `BaseEntry` | `itr.DocEntry` | |
| `BaseLine` | `line.LineNum` de la ITR | |
---
## `DeliveryNotes` (`domain/delivery.py`, solo si `is_store: true`)
Se arma a partir de la **Invoice** (base) y el **StockTransfer** ya creado
(para saber qué se transfirió realmente en este fulfillment).
| Campo SAP | Origen | Nota |
|---|---|---|
| `CardCode` / `CardName` | `invoice.CardCode` / `invoice.CardName` | |
| `Comments` | `invoice.NumAtCard` | |
| `DocDate` | `invoice.DocDate` | |
| `Series` | — | `sap.numbering_series.DELIVERY` |
| `U_ShopifyOrderId` / `U_ShopifyStore` | copiados de la Invoice | |
### `DocumentLines` (una por línea del StockTransfer, emparejada con la Invoice por `ItemCode`)
| Campo SAP | Origen | Nota |
|---|---|---|
| `ItemCode` / `Quantity` | línea del StockTransfer | solo lo de este fulfillment, no toda la orden |
| `WarehouseCode` | línea de la Invoice con el mismo `ItemCode` | |
| `BaseType` | — | `13` (ObjType clásico de Sales Invoice, `oInvoices`) |
| `BaseEntry` | `invoice.DocEntry` | |
| `BaseLine` | `LineNum` de la línea de la Invoice | |
---
## Referencias cruzadas en la Invoice (`agregar_referencia_documento`)
Cada vez que se crea ITR, StockTransfer o Delivery, se hace `PATCH
Invoices` agregando una entrada a `DocumentReferences` (sin pisar las que ya
existan):
| Documento creado | `RefObjType` |
|---|---|
| `InventoryTransferRequests` | `rot_InventoryTransferRequest` |
| `StockTransfers` | `rot_InventoryTransfer` |
| `DeliveryNotes` | `rot_DeliveryNotes` |
---
## Campos fijos / hardcode (pendientes de hacer configurables si hace falta)
| Campo | Valor | Dónde |
|---|---|---|
| `TaxCode` (líneas de Invoice) | `"IVA"` | `domain/invoice.py` |
| `TaxCode` (dirección ShipTo del BP) | `"IVA"` | `domain/business_partner.py` |
| `DocCurrency` | `"CLP"` | Invoice, Payment |
| Datos de tarjeta (Payment) | últimos dígitos `"1234"`, vencimiento `"2030-12-31"` | `domain/payment.py` (provisorios) |
| `GroupCode` (BusinessPartner) | `100` | `domain/business_partner.py` |
| `BaseType` ITR→StockTransfer | `1250000001` | `domain/stock_transfer.py` |
| `BaseType` Invoice→Delivery | `13` | `domain/delivery.py` |
+87
View File
@@ -0,0 +1,87 @@
# Pendientes / roadmap
Estado al 2026-09-30. Ver `docs/flujo_pipeline.md`, `docs/mapeo_campos.md` y
`docs/casos_especiales.md` para lo construido y probado contra SAP/Shopify reales
(ambas tiendas: snobbycl y on-chile-distributor-prod).
## Antes de pasar al QA del cliente
1. **Correr `main.py` solo** varios ciclos seguidos (el loop real del scheduler, no las
herramientas): lock, cursor, estados nuevos (`cancelled`, `ignored`), rotación de reintentos.
2. **Revisar la configuración de cada tienda contra el SAP de QA**: tarjetas de crédito
(`sap.payment_methods[*].credit_card`) y sus cuentas, series de numeración, bodegas
(`default_warehouse_invoice`, `locations`), `generic_card_code`, cuenta de reversso
(`1-1-010-20-000`), grupo de artículos (108), grupo de clientes (100), `TaxCode IVA`,
tipo de cambio USD cargado.
3. **Datos provisorios de tarjeta** (`domain/payment.py`): últimos dígitos `1234` y vencimiento
`2030-12-31`. Confirmar con contabilidad.
4. **Secretos**: ya están fuera del JSON (`.env` / variables de entorno). Falta definirlos en el
equipo de QA, y evaluar `verify_ssl: true` en `sap_connection`.
5. **Zona horaria**: el QA está en UTC-3 (America/Santiago) y el desarrollo en UTC-5. La BD guarda UTC y
`tools/consultar` muestra hora local (`APP_TIMEZONE`). **`main.py` usa la hora local del equipo**
para las ventanas de `scheduler.json`; hay que confirmar que el servidor de QA tenga su zona correcta
o fijar la zona en el scheduler.
6. **Limpieza de artefactos de prueba**: `download/`, respaldos de la BD en `data/`,
artículos y entradas de mercancía de prueba (precio 5000, cuenta `1-1-080-40-000`) de `SBODemoCL`.
7. **Repositorio git**: el proyecto **no es un repositorio** (no hay `.git`); el `.gitignore` ya existe. Falta
`git init`, primer commit y definir el remoto. Antes de subir, verificar que `git status` no incluya `.env`,
`data/*.db`, `logs/` ni `download/`.
8. ~~Fallback de órdenes manuales~~: retirado (carpeta `ordenes_manuales/` y `pipeline/manual_orders.py`); un error de
la API vuelve a verse como error de tienda.
9. **Gateways nuevos**: un gateway que no esté en `sap.payment_methods` solo se detecta al crear el pago (la
Invoice ya existe y la orden queda en `error`). Conviene validar los gateways de la orden **antes** de crear la
Invoice, o alertar apenas aparezca uno nuevo (hoy: `Venti`/`Transbank` en on-chile, `PayPal` configurado sin uso).
10. **Tests automáticos**: no hay carpeta de tests. Los JSON reales de `download/` sirven de datos para probar
`construir_invoice`, `construir_incoming_payment` y `fulfillments_facturados` sin tocar SAP.
## Decisiones de negocio abiertas
- **Pedidos de prueba**: hoy se marcan a mano como `ignored` (#8019). Definir si se detectan automáticamente (por
etiqueta, monto o cliente) para que no lleguen a SAP de QA. #8020 (1.000 CLP) se integró y podría ser de prueba.
- **Costo de envío**: toda orden con `total_shipping_price_set > 0` queda en `error` (no se definió cómo
reflejar el flete en la Invoice). En septiembre fue 1 de 1.160 órdenes en on-chile (#CL10169).
- **Reembolsos o anulaciones posteriores a integrar**: `integrated` es terminal; SAP queda con la venta
completa, sin nota de crédito ni devolución. Hoy el reembolso se trata como interno de comercial.
- **Envío real de la notificación de errores**: `generar_reporte_errores_html()` solo guarda el HTML en
`notifications/`. Falta definir a quién, con qué frecuencia y qué contenido.
- **Instalación como servicio de Windows**: falta empaquetarlo (ej. NSSM) en el equipo de destino.
## Datos que quedaron abiertos en la BD de desarrollo
- **on-chile — carga inicial**: 1.159 órdenes `pending` desde el 01/09 (1.049 elegibles, 73 sin despachar, 22 anuladas,
15 con pago pendiente, 1 con envío). Artículos y stock ya creados en `SBODemoCL` y gateways configurados; falta
integrarlas (en tandas, deteniéndose ante el primer error) y revisar #CL10851 (volvió a entrar como `pending`).
- **snobbycl — #7863**: queda en `error` (Delivery: documento base cerrado) y se reintenta en cada ciclo sin resolverse.
- **snobbycl — #8055**: pagada, sin despachar; se integra sola al despacharse.
- **Clientes**: integrar las órdenes de on-chile crea ~977 BusinessPartners nuevos en SAP (casi todas traen RUT).
## Operación
- **Respaldo y retención** de la BD SQLite (`data/shopify_sap.db`) y de `logs/` (hoy solo hay rotación de logs).
- **`docs/analisis_tiempos.md`** mide ~4-5 s por orden; en las pruebas de hoy fue ~5 s (CD) a ~7 s (tienda, con 5
documentos). Actualizar la estimación de capacidad (`max_orders_per_cycle`).
## Pruebas pendientes
- **Fulfillment parcial real** (`in_progress` que se completa en un ciclo posterior). Nunca se probó de punta
a punta con una orden real (hay órdenes `partial` en on-chile).
- **Desincronización SQLite/SAP**: la adopción de documentos existentes (ver abajo) está probada con la BD
bloqueada para escrituras; falta observarla en un ciclo real.
- **Volumen**: ~7 s por orden. La carga inicial de on-chile desde el 01/09 son ~1.150 órdenes (~2 horas).
## Ya resuelto (no repetir)
- Pipeline completo en ambas tiendas: Invoice, pago con tarjeta por transacción (incluye pagos divididos),
ITR, StockTransfer y Delivery; camino de tienda y de CD.
- Descarga real de cabeceras en ambas tiendas (scope `read_orders` aprobado en on-chile).
- **Idempotencia contra SAP**: antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento
existente de la orden (`U_ShopifyOrderId`, `U_ShopifyStore`, mismas líneas) y se adopta; una Invoice ya
pagada no genera otro pago.
- **Reintentos de red** en `ShopifyClient` (4 intentos con espera creciente).
- Invoice igual a lo cobrado (líneas canceladas o devueltas, reembolsos), fulfillments limitados a lo facturado,
órdenes anuladas (`cancelled`), pedidos ignorados (`ignored`), despachadas y luego reembolsadas se integran.
- Cursor con piso `initial_since_id`; corte de on-chile en el 01/09/2026.
- Secretos en `.env` (`core/config.py`), `.gitignore`, `requirements.txt` fijado.
- Fallback de órdenes manuales retirado (solo se usó mientras no se podía conectar a on-chile).
- Herramientas: `consultar`, `integrar_ordenes`, `probar_pago`, `descargar_ordenes_especiales`,
`entrada_mercancia_ordenes`, `cargar_items_faltantes`.