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