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