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

+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` |