# API REST v1 — para conectores

Base: `{base_url}`, que ya incluye `/api/v1`. JSON en UTF-8. Todo se limita a la tienda dueña de la llave.

| Entorno | `{base_url}` |
|---|---|
| Servidor público (VPS) | `https://retail-s.online/api/v1` |
| Local | `http://127.0.0.1:8000/api/v1` |

Las rutas de este documento se agregan a esa base: `GET /products/` es `GET {base_url}/products/`. Las peticiones por http al
servidor público reciben un 301 a https.

Documentación en línea, OpenAPI 3.1, colección de Postman y prompts para IA: `/docs/` del servidor de plataforma. El contrato
completo (rutas, parámetros, cuerpos, permisos) está en [`openapi.json`](openapi.json) y la colección en
[`postman/`](postman/); esta página tiene las reglas de negocio de cada recurso.

> **Entorno local con CA propia (solo servidor de pruebas en la LAN).** El servidor de la casa usa un certificado de una CA
> casera: el conector debe confiar en `casa-ca.crt` (en curl, `--cacert casa-ca.crt`). No aplica al servidor público.

## Autenticación

Genera una llave en el back office (**Configuración → Llaves de API**) y envíala así:

```
Authorization: Bearer rk_1a2b3c4d_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

- Sin llave o con una llave inválida o desactivada: `401`.
- La sesión del back office **no** sirve para la API.

`GET /api/v1/` devuelve la tienda, el nombre de la llave y sus `scopes` (sirve para probar la conexión).

### Permisos (scopes)

Cada llave tiene permisos por área, que se eligen al crearla: sin acceso, leer o leer y escribir.
Una petición `GET` pide `read_<área>`; `POST`, `PUT`, `PATCH` y `DELETE` piden `write_<área>`. Escribir incluye leer.
Sin el permiso: `403` con `{"detail": "La llave no tiene el permiso «write_orders»."}`.

| Área | Scopes | Recursos |
|---|---|---|
| Catálogo | `read_products`, `write_products` | productos, variantes, imágenes, categorías, marcas, colecciones, atributos, plantillas de variantes, traducciones |
| Inventario | `read_inventory`, `write_inventory` | inventario, ubicaciones |
| Precios | `read_prices`, `write_prices` | precios, listas de precios |
| Clientes | `read_customers`, `write_customers` | clientes, grupos de clientes |
| Pedidos | `read_orders`, `write_orders` | pedidos, cambios de estado, devoluciones, reembolsos |
| Integraciones | `read_integrations`, `write_integrations` | conexiones, referencias externas |

Las llaves creadas antes del 15 oct 2026 tienen todos los permisos.

## Convenciones

- Los registros se identifican por **código** (como en BC): categorías, atributos, ubicaciones,
  listas de precios y productos. Las variantes, por **SKU**.
- Los códigos se guardan en mayúsculas: `ply-001` y `PLY-001` son el mismo producto.
- Lo que no tiene código (clientes, pedidos y sus líneas y devoluciones, imágenes, conexiones y referencias
  externas) se identifica por su **`id` público**: un UUID (v7, ordenado por tiempo) como
  `"0192f3a4-6b1c-7d2e-9f00-1a2b3c4d5e6f"`. La API nunca expone ni acepta los IDs internos (numéricos).
  Guarda el `id` como texto.
- **`PUT /<recurso>/<código>/` es un upsert:** crea el registro (`201`) o lo reemplaza (`200`).
  `PATCH` actualiza solo los campos enviados y da `404` si no existe.
- Montos y cantidades viajan como texto decimal (`"199.0000"`) para no perder precisión.
- Listados paginados **por cursor**: `?limit=100` (de 1 a 500; por omisión 100). Respuesta:
  `{"next": url, "results": [...]}`. Para la página siguiente se pide la URL de `next` tal cual (lleva un
  `cursor` opaco); en la última página `next` es `null`. Los registros salen en orden de alta, así que una alta
  durante la lectura aparece al final y ninguno se repite ni se salta. No hay `count` ni `previous`, y `offset`
  responde `400`.
- Sincronización incremental: `?updated_since=2026-10-14T00:00:00Z` en los listados por código.
- Errores de validación: `400` con el detalle por campo.

## Recursos

| Recurso | Rutas |
|---|---|
| Plantillas de variantes | `GET/POST /variant-templates/` · `GET/PUT/PATCH/DELETE /variant-templates/{code}/` |
| Marcas | `GET/POST /brands/` · `GET/PUT/PATCH/DELETE /brands/{code}/` |
| Colecciones | `GET/POST /collections/` · `GET/PUT/PATCH/DELETE /collections/{code}/` (`products`: lista de códigos, declarativa; `is_automatic` y `rules` son de solo lectura: las reglas se editan en el back office) |
| Categorías | `GET/POST /categories/` · `GET/PUT/PATCH/DELETE /categories/{code}/` |
| Grupos de modificadores | `GET/POST /modifier-groups/` · `GET/PUT/PATCH/DELETE /modifier-groups/{code}/` |
| Atributos | `GET/POST /attributes/` · `GET/PUT/PATCH/DELETE /attributes/{code}/` |
| Ubicaciones | `GET/POST /locations/` · `GET/PUT/PATCH/DELETE /locations/{code}/` |
| Listas de precios | `GET/POST /price-lists/` · `GET/PUT/PATCH/DELETE /price-lists/{code}/` |
| Grupos de clientes | `GET/POST /customer-groups/` · `GET/PUT/PATCH/DELETE /customer-groups/{code}/` |
| Clientes | `GET/POST /customers/` · `GET/PUT/PATCH/DELETE /customers/{id}/` |
| Paqueterías | `GET /carriers/` · `GET /carriers/{code}/` (solo lectura, con sus servicios; permiso `read_orders`) |
| Medios de pago | `GET /payment-methods/` · `GET /payment-methods/{code}/` (solo lectura; permiso `read_orders`) |
| Pedidos | `GET/POST /orders/` · `GET /orders/{id}/` · `POST /orders/{id}/{pay,prepare,ship,deliver,cancel,returns,refunds}/` |
| Productos | `GET/POST /products/` · `GET/PUT/PATCH/DELETE /products/{code}/` |
| Importar catálogo | `POST /products/import/` (archivo NDJSON) · `GET /products/import/{id}/` · `GET /products/import/{id}/rows/` |
| Duplicar producto | `POST /products/{code}/duplicate/` `{"code", "name", "copy_images", "copy_prices"}` |
| Imágenes | `POST /products/{code}/images/` · `DELETE /products/{code}/images/?external_key=` · `DELETE /products/{code}/images/{id}/` |
| Variantes | `GET /variants/` · `GET/PATCH/DELETE /variants/{sku}/` |
| Precios | `GET /prices/` · `POST /prices/bulk/` (`?partial=true`) |
| Inventario | `GET /inventory/` · `POST /inventory/bulk/` (`?partial=true`) |
| Conexiones | `GET /connections/` · `GET /connections/{id}/` (solo lectura) · `GET/POST /connections/{id}/sync-runs/` (corridas de sincronización) |
| Traducciones | `GET /translations/?object_type=product&object_key=CODIGO[&language=en]` · `PUT /translations/` `{"object_type", "object_key", "language", "fields": {...}}` |
| Referencias externas | `GET/POST /external-references/` · `DELETE /external-references/{id}/` |

### Categorías

```json
PUT /categories/PLAYERAS/
{"name": "Playeras", "parent": "ROPA", "is_active": true}
```

Campos: `name`, `parent` (código o `null`), `slug` (se genera si falta), `description`,
`position`, `is_active`.

### Atributos

```json
PUT /attributes/MATERIAL/
{"name": "Material", "type": "option", "options": ["Algodón", "Poliéster"], "is_filterable": true}
```

`type`: `text`, `integer`, `decimal`, `date`, `boolean`, `option` (los mismos de BC).
`options` deja la lista exactamente como se envía: si quitas un valor, se borra de los productos.
No se puede cambiar el `type` si algún producto o variante ya tiene valor.

`scope` (#78): `product` (por defecto) o `variant`. Un atributo de producto lleva un valor por producto y se envía en
`attributes` del producto; uno de variante lleva un valor por variante (temporada, peso…) y se envía en
`attributes` de la variante. Cada código solo se acepta en su nivel (si no, `400`). No se puede cambiar el `scope`
si ya tiene valores.

### Modificadores

```json
PUT /modifier-groups/EXTRAS/
{"name": "Extras", "min_select": 0, "max_select": 2,
 "modifiers": [{"code": "QUESO", "name": "Queso", "price": "0.75"}, {"code": "TOCINO", "name": "Tocino", "price": "1.25"}]}
```

Opciones que se eligen al comprar y no son variantes («Extras», «Término de la carne», «Grabado»). `min_select` 0 es
opcional; `max_select` `null` es sin límite. `modifiers` es declarativo (el grupo queda con esos, por código); los
códigos de modificador son únicos en la tienda. `price` es el precio extra con la misma convención de impuestos que la
tienda. En el producto, `modifier_groups` es la lista de códigos de grupo, en orden (declarativa). En `POST /orders/`,
cada línea acepta `"modifiers": ["QUESO", ...]`; se validan contra los grupos del producto (mínimo, máximo y que sean
suyos) y cada uno va como línea hija con `parent_line`. Referencias externas: `object_type` `modifier_group` y
`modifier` (llave: el código).

### Ubicaciones, tipo y horario

- `kind`: `store` (tienda, por defecto) o `warehouse` (bodega).
- `allows_pickup`: desde esta sucursal se surten los pedidos de recoger (`pickup`, `takeout`, `dine_in`).
- `ships_orders`: desde esta sucursal se surten los pedidos de envío (`shipping`, `delivery_app`).
  Sin `location` en `POST /orders/`, se elige la primera sucursal activa con la bandera del tipo de pedido que alcanza
  para todo (si ninguna alcanza, la primera con la bandera; si no hay ninguna, `400`). Un `location` explícito se respeta.
- `fulfills_online_orders` se conserva por compatibilidad y es la unión (`allows_pickup` o `ships_orders`): es lo que
  usan la tienda (existencia visible, horario). Un conector que solo manda `fulfills_online_orders` fija las dos
  banderas a ese valor; si manda alguna bandera, `fulfills_online_orders` se recalcula y se ignora el que venga.
  Las sucursales anteriores se migraron con ambas banderas iguales a `fulfills_online_orders`.

`hours` (opcional, declarativo) es el horario de atención de la sucursal:
`[{"weekday": 0, "opens": "11:00", "closes": "15:00"}, {"weekday": 0, "opens": "18:00", "closes": "02:00"}]`
(`weekday` 0 = lunes … 6 = domingo, hora local de la tienda; si `closes` es menor que `opens`, cruza la medianoche).
Sin rangos, la sucursal no tiene horario (siempre abierta). Si la tienda tiene activo «no aceptar pedidos fuera de
horario» y ninguna sucursal que surte pedidos en línea está abierta, `POST /orders/` responde `400` con el mensaje de
cuándo vuelve a abrir (los pedidos del back office sí se aceptan).

### Listas de precios por grupo

`customer_groups` (lista de códigos) limita la lista a clientes de esos grupos. Sin grupos,
aplica a todos. La lista predeterminada no puede llevar grupos.

### Precios e impuestos

El precio que devuelve la API es el capturado en cada lista, con su marca `prices_include_tax` (si ese
precio ya incluye el impuesto). La conversión a la convención de la tienda (con o sin impuesto) la hace
la tienda en línea y también la creación de pedidos; la API no convierte. La marca de la lista
predeterminada sigue a la configuración de la tienda: la API no permite cambiarla en esa lista.

### Clientes

```json
POST /customers/
{
  "code": "C-100",
  "first_name": "Carlos", "last_name": "Hernández", "company": "Uniformes del Bajío",
  "email": "compras@ejemplo.com", "phone": "477 555 0101", "tax_id": "UBA010101AB1",
  "group": "MAYORISTAS", "accepts_marketing": false, "notes": "",
  "addresses": [
    {"label": "Matriz", "line1": "Blvd. López Mateos 3401", "line2": "Col. Jardines del Moral",
     "city": "León", "state": "Guanajuato", "postal_code": "37160", "country": "MX"}
  ]
}
```

- Se identifican por `id` (UUID). `code` y `email` son opcionales, pero únicos en la tienda si se usan.
  Para buscar: `GET /customers/?email=...`, `?code=...`, `?group=MAYORISTAS`, `?q=texto`,
  `?updated_since=...`.
- `addresses` es declarativo: si lo envías, reemplaza todas las direcciones; si no, no se tocan.
  La primera queda como predeterminada de envío y facturación, salvo que marques otra con
  `is_default_shipping` / `is_default_billing` (solo una de cada una).
- Para mapear con otro sistema: referencias externas con `object_type` `customer` (llave: `code`)
  o `customer_group`.

### Pedidos

```json
POST /orders/
{
  "customer_email": "compras@ejemplo.com",
  "lines": [
    {"sku": "PLY-002-NEGRO-M", "quantity": 6},
    {"sku": "COC-002", "quantity": 1, "unit_price": "14.50"},
    {"sku": "COC-001-NEGRO", "quantity": 2, "unit": "CAJA6"}
  ],
  "shipping_address": {"line1": "Blvd. Los Próceres 12", "city": "Antiguo Cuscatlán",
                       "state": "La Libertad", "country": "SV"},
  "location": "CEDIS",
  "discount": "0", "shipping": "8.00", "notes": ""
}
```

- `delivery_type`: `shipping` (envío, por omisión), `pickup` (el cliente pasa por él; sin dirección de envío) y, si la
  tienda los tiene activos, `takeout` (para llevar) y `dine_in` (en mesa, con `table_number`). Un tipo que la tienda
  no acepta responde `400`.
  `carrier_code` y `carrier_service_code` (opcionales, solo en pedidos `shipping`): paquetería y servicio de la tienda
  (`GET /carriers/`); un código que no existe responde `400`. Son una copia para el ERP: **`shipping` debe traer ya el
  cargo del servicio** (la API no lo suma); la tienda en línea sí lo suma al envío y lo deja en `carrier_fee`.
  `payment_method` (opcional): `code` de un medio de pago de la tienda (`GET /payment-methods/`); uno que no existe
  en la tienda responde `400`. Si el medio tiene horas de apartado, el pedido nace con `reservation_expires_at`.
- El cliente se indica con `customer` (su `id`) o `customer_email`; sin cliente es venta de invitado
  (`customer_name`, `email`, `phone` opcionales). Sin `shipping_address` se usa la predeterminada
  del cliente; sin `billing_address`, la de envío. Sin `location`, la primera que surte ese tipo de pedido
  (ver «Ubicaciones, tipo y horario») y alcanza para todo.
- Sin `unit_price`, cada línea toma el precio que le toca al cliente. El pedido se rechaza (400) si
  no hay existencia suficiente o falta un precio.
- **Reintentos seguros:** `POST /orders/`, `POST /orders/{id}/pay/` y `POST /orders/{id}/returns/` aceptan la cabecera
  opcional `Idempotency-Key` (hasta 100 caracteres y sin caracteres de control; si no, responde `400`), igual que los reembolsos. La llave es por
  tienda. Con la misma llave y los mismos datos, `POST /orders/` responde `201` la primera vez y `200` con el mismo
  pedido al repetirla, sin crear otro; `pay` responde `200` las dos veces y no registra otro pago; `returns` responde
  `201` y luego `200` sin registrar otra devolución. La misma llave con otros datos (o, en `pay` y `returns`, en otro
  pedido) responde `409` y no cambia nada. Con llamadas simultáneas y la misma llave se hace una sola operación.
  Al repetir con la llave solo se comparan la llave y los datos: no se vuelve a validar el horario de la tienda
  (la repetición de un pedido ya creado responde `200` aunque la tienda haya cerrado); una llave nueva se valida completa.
  Sin la cabecera nada cambia: `pay` repetido sigue respondiendo `409` («ya está pagado»). No se guarda la
  respuesta original ni caduca: la repetición devuelve el pedido como esté en ese momento.
- Se identifican por `id` (UUID). `number` es el número que ve la tienda (`#1001`): para buscar por él,
  `GET /orders/?number=1001`. Filtros: `?number=`, `?status=`, `?payment_status=`, `?fulfillment_status=`, `?delivery_type=`,
  `?customer={id del cliente}`, `?placed_since=...`, `?updated_since=...`.
- Cada línea y cada devolución traen su propio `id` (UUID). Una línea con `parent_line` es un modificador de esa
  línea (p. ej. «extra queso»): trae su propio precio e importe y la misma `quantity` que la línea padre (uno por
  cada unidad), y los totales del pedido suman todas las líneas por igual. Al devolver la línea padre se devuelven sus
  hijas en la misma proporción.
- **Estados**, cada uno por separado:
  - `status`: `open`, `cancelled`, `archived`. Un pedido cancelado y luego archivado sigue teniendo `cancelled_at`.
  - `payment_status`: `pending`, `authorized`, `paid`, `partially_refunded`, `refunded`, `failed`.
  - `fulfillment_status`: `unfulfilled`, `prepared`, `ready_for_pickup` (solo `pickup`), `shipped` (solo `shipping`),
    `delivered`.
  - `delivery_type`: `shipping`, `pickup` (y, para restaurante, `dine_in`, `takeout`, `delivery_app`).
  - `payment_method`: `code` del medio de pago (o vacío) y `payment_method_name`, su nombre al hacer el pedido.
    `reservation_expires_at`: hasta cuándo dura el apartado si el pedido no se paga (según las horas de apartado del
    medio; `null` = sin vencimiento en los pedidos del back office y la API; los de la tienda en línea usan
    `STOREFRONT_UNPAID_MAX_HOURS`, 72 h por omisión). Al vencer sin pago, el pedido pasa a `cancelled` con `cancel_reason`
    «El apartado venció sin pago.», se libera la existencia y se emite `order.cancelled`. Un pedido `authorized`
    no vence. Un pago en línea que llega después queda en el pago con `needs_review` y `review_reason` `late`.
- **Paquetería:** el pedido trae `carrier` (nombre), `carrier_code`, `carrier_service_code`, `carrier_service_name`,
  `carrier_fee` (la parte de `shipping_total` que es del servicio), `tracking_number` y `tracking_url`.
  `GET /carriers/` lista las de la tienda (también las desactivadas, porque los pedidos viejos conservan su código):
  `code`, `name`, `tracking_url_template` (con `{tracking}`), `is_active`, `position` y `services` (`code`, `name`,
  `level` `standard` o `express`, `fee`, `is_active`, `position`). Los eventos `order.shipped` y demás llevan
  `carrier`, `carrier_code`, `carrier_service_code`, `carrier_fee`, `tracking_number` y `tracking_url`.
- **Medios de pago:** `GET /payment-methods/` (solo lectura; permiso `read_orders`) lista los de la tienda: `code`,
  `name`, `kind` (`card`, `payment_link`, `transfer`, `cash_on_delivery`, `in_store`, `other`), `is_active`,
  `position` y `reservation_hours` (`null` = sin vencimiento para los pedidos del back office y la API; los de la tienda en línea
  usan `STOREFRONT_UNPAID_MAX_HOURS`, 72 h por omisión). Los desactivados también salen: los pedidos viejos
  conservan su código. Cada tienda trae `card`, `transfer`, `cash_on_delivery`, `in_store` y `other`; el comerciante
  los edita en Configuración > Pagos.
- Cambios de estado (responden con el pedido completo, o 409 si el paso no aplica):
  - `POST /orders/{id}/pay/` `{"method": "transfer", "reference": "T-123"}`
  - `POST /orders/{id}/prepare/`: además de preparar, deja el sello informativo `erp_accepted_at` (ver «Inventario con ERP»).
  - `POST /orders/{id}/ship/` `{"carrier_code": "C807", "tracking_number": "..."}`: descuenta la existencia. Con
    `carrier_code` (una paquetería de la tienda; `400` si no existe) se guarda su nombre y su código y se calcula
    `tracking_url` con su plantilla; con `carrier` (texto) se busca primero por código o nombre entre las paqueterías de la
    tienda y, si coincide, equivale a mandar su `carrier_code`; si no coincide es texto libre y no hay enlace. Si no se manda ninguno, se conserva la
    paquetería con que se hizo el pedido.
  - `POST /orders/{id}/ready/`: listo para recoger (pedidos `pickup`); descuenta la existencia igual que `ship`.
  - `POST /orders/{id}/deliver/`: desde `shipped` o `ready_for_pickup`.
  - `POST /orders/{id}/cancel/` `{"reason": "..."}`: libera lo apartado. No aplica si ya salió del inventario.
  - `POST /orders/{id}/archive/`: solo pedidos cancelados, entregados o reembolsados. `POST /orders/{id}/unarchive/`
    lo regresa a como estaba. Un pedido archivado no acepta otros cambios.
  - `POST /orders/{id}/returns/` `{"lines": [{"line_id": "<id de la línea>", "quantity": 1}], "reason": "Talla",
    "restock": true, "refund_amount": null}`. Sin `refund_amount` se usa el sugerido.
  - `POST /orders/{id}/refunds/` `{"amount": "25.00", "reason": "Producto dañado", "payment": "<id del pago>"}`:
    reembolsa parte o todo un pago cobrado (ver «Pagos»). `payment` es opcional: sin él se usa el pago cobrado del
    pedido; un pago `needs_review` solo se reembolsa por la API pasando `payment`. Responde `201` con el pedido ya actualizado; `400` si el monto no es positivo, tiene más de 2 decimales o
    falta el motivo; `404` si el pedido o el pago no son de tu tienda, o si el pago es de otro pedido de la misma tienda; `409` si el
    pedido está archivado (no acepta reembolsos), si el monto supera lo disponible del pago,
    el pago no está cobrado o la pasarela no lo procesa (en ese caso no cambia nada). Cabecera opcional
    `Idempotency-Key` (hasta 100 caracteres): repetir la llamada con la misma llave no reembolsa otra vez y responde
    `200` con el pedido como esté en ese momento; usar la misma llave en otro pedido, o en el mismo con otro monto u
    otro pago, responde `409` y no cambia nada. Con llamadas simultáneas y la misma llave se hace un solo reembolso:
    una responde `201` y las demás `200`.
- **Pagos:** `GET /orders/{id}/payments/` lista los intentos de cobro del pedido (solo lectura): `id`, `provider`
  (`manual` si se registró a mano, o la pasarela), `method`, `status` (`pending`, `authorized`, `paid`,
  `partially_refunded`, `refunded`, `failed`, `expired`), `amount`, `currency`, `refunded_amount`,
  `provider_reference`, `needs_review` y `review_reason` (`late`, `amount_mismatch`, `duplicate`). Todo pedido
  pagado tiene uno.
  - Cada pago trae `refunds`: sus reembolsos, del más viejo al más nuevo, con `id`, `amount`, `currency`, `status`
    (`pending`, `done`, `failed`), `reason`, `provider_reference`, `created_at` y `completed_at`. `refunded_amount` es
    la suma de los `done`; lo que queda por reembolsar es `amount` menos `refunded_amount` y menos los `pending`. Un
    pago admite varios reembolsos parciales, nunca por más de lo cobrado.
  - El reembolso es síncrono: con una pasarela se pide a la pasarela en la misma llamada y, si falla, la respuesta es
    `409` y no queda rastro. El de un pago `manual` solo se anota (el dinero lo devuelves tú). Un pago `needs_review`
    también se puede reembolsar; en ese caso el pedido no cambia.
- Para mapear con otro sistema (p. ej. el pedido de venta de BC): referencias externas con
  `object_type` `order` (llave: el número).

### Productos

```json
PUT /products/PLY-001/
{
  "name": "Playera básica",
  "status": "active",
  "category": "PLAYERAS",
  "short_description": "...",
  "description": "...",
  "base_unit": "PZA",
  "tax_exempt": false,
  "tax_code": "IVA13",
  "weight": "0.2",
  "track_inventory": true,
  "options": [
    {"name": "Color", "values": ["Negro", "Blanco"]},
    {"name": "Talla", "values": ["CH", "M", "G"]}
  ],
  "variants": [
    {"code": "NEGRO-CH", "sku": "PLY-001-N-CH", "barcode": "7501234567890"}
  ],
  "attributes": {"MATERIAL": "Algodón", "GARANTIA": 12}
}
```

- `status`: `draft`, `active`, `archived`.
- **`options`** (opcional) es declarativo: el producto queda exactamente con esas opciones y
  valores. Las variantes se generan solas (una por combinación). Las que siguen siendo válidas
  conservan sus precios e inventario. `"options": []` deja solo la variante predeterminada.
  Si no envías `options`, no se tocan.
- **Color de los valores** (#52): cada elemento de `values` puede ser un texto (`"Rojo"`, como siempre) o un objeto
  `{"value": "Rojo", "color": "#C92A2A"}`. `color` es hexadecimal `#RRGGBB` (se guarda en mayúsculas); si no viene, no se
  cambia el color que ya tenía el valor, y `""` o `null` lo quita. Un color inválido es `400`. La lectura sigue trayendo
  `values` como textos y suma `colors` (`{"Rojo": "#C92A2A"}`, solo los valores con color); cada variante trae
  `option_colors` (`{"Color": "#C92A2A"}`). La tienda lo muestra como muestra de color. Las plantillas de variantes
  (`/variant-templates/`) siguen solo con textos.
- **`variants`** (opcional) actualiza datos de variantes existentes, buscándolas por `code` (obligatorio, salvo con
  `variants_mode: "exact"`).
  El código se forma con los valores (`NEGRO-CH`); la variante predeterminada tiene código `""`.
  Se pueden cambiar `sku`, `barcode`, `weight` e `is_active`.
- **`variants_mode: "exact"`** (opcional, #68; sin él todo funciona como arriba). Para sistemas que ya tienen sus
  variantes (p. ej. LS): el producto queda **solo** con las variantes de `variants`, sin generar las demás combinaciones,
  en una sola llamada. Pide `options` (con todos los valores que usan las variantes) y `variants`; cada variante trae
  `options` (`{"Color": "Negro", "Talla": "CH"}`, un valor por opción, sin importar mayúsculas) y, opcional, `code`
  (el código que fijas; si falta se calcula con los valores) además de `sku`, `barcode`, `weight`, `is_active` y
  `attributes`. Una combinación que ya existe se conserva (con precios e inventario) y toma el código y SKU enviados;
  las existentes que no se listan se borran, o se desactivan (`is_active: false`) si tienen pedidos o existencia.
  Es `400` si falta una opción o un valor no existe, si hay combinaciones, códigos o SKUs repetidos, si el SKU ya lo
  usa otro producto o si se listan más de 250 variantes (el tope cuenta las listadas, no las combinaciones posibles).
  `POST /products/bulk/` acepta lo mismo en cada producto. Un `PUT` posterior sin este modo vuelve a generar todas las
  combinaciones de `options`.
- **`attributes` en cada variante de `variants`** (opcional, #78): `{"CODIGO": valor}` con atributos de `scope`
  `variant`, mismas reglas que a nivel producto (`null` borra; código desconocido o de producto es error, y el `PUT`
  no se aplica). La lectura trae `attributes` en cada variante.
- **`attributes`** (opcional): `{"CODIGO": valor}`. `null` borra el valor; los atributos que no
  envíes no se tocan. Para tipo `option` se envía el texto del valor (sin importar mayúsculas).
- Todo el `PUT` se aplica completo o no se aplica nada.

**Para BC sin LS:** BC no maneja dimensiones de variante. Lo más simple es mandar una sola
opción (p. ej. `"Variante"`) con las descripciones de las Item Variants como valores, y luego
fijar en `variants` el `code` y el `sku` que correspondan.

La respuesta (y `GET /products/{code}/`) incluye las variantes con sus opciones, existencias por
ubicación y precios, las imágenes con URL y los atributos.

Filtros del listado: `status`, `category` (código), `q` (nombre, código o SKU), `updated_since`.

### Alta masiva de productos

Para primeras cargas (decenas de miles de productos). `PUT /products/{code}/` hace una transacción por producto; esto
envía hasta **500 productos** por llamada, los procesa el worker en segundo plano y deja el resultado fila por fila.
Requiere el permiso `write_products` (consultar el avance, `read_products`).

```json
POST /products/bulk/
{"items": [{"code": "PLY-001", "name": "Playera básica", "options": [...], "variants": [...]}, {"code": "PLY-002", ...}]}

202 {"id": "01a1…", "status": "pending", "total": 2, "processed": 0, "created": 0, "updated": 0, "failed": 0,
     "created_at": "…", "started_at": null, "finished_at": null}
```

- Cada elemento de `items` tiene **el mismo formato que el cuerpo de `PUT /products/{code}/`** más su `code` (upsert por
  código, mayúsculas): crea el producto o lo actualiza, con opciones, variantes, atributos, etc. Las opciones se
  procesan como en el `PUT`.
- La llamada solo revisa la **forma** y responde `400` (sin crear nada) si `items` no es una lista con 1 a 500
  elementos, si algún elemento no es un objeto con `code` (texto, máximo 20) o si un código viene repetido. El resto de
  la validación (categoría inexistente, SKU repetido, atributos, etc.) la hace el worker, producto por producto.
- **Un producto malo no tumba a los demás:** cada producto se guarda en un punto de guardado propio dentro de la
  transacción de su tanda; si falla, no queda nada de ese producto y se anota el error en su fila. No hay modo «todo o
  nada». El avance de la tanda (`processed`, `rows`) se confirma junto con sus productos.
- Los productos se procesan en orden y por tandas de hasta 100 (o 20 s), una transacción por tanda; entre tandas el
  worker atiende eventos y webhooks. Un `PUT` o una alta masiva sobre el mismo código en paralelo no están coordinados: la última escritura gana.
- **Eventos:** el alta masiva, igual que `PUT /products/{code}/`, no emite eventos ni webhooks (no existe evento de
  producto); crear existencias o precios es aparte (`/inventory/bulk/`, `/prices/bulk/`).
- `GET /products/bulk/{id}/` devuelve el estado (`pending`, `running`, `done`), el avance (`processed` de `total`, con
  `created`, `updated` y `failed`) y `rows`, un renglón por producto procesado, en el orden enviado:

```json
{"id": "01a1…", "status": "done", "total": 2, "processed": 2, "created": 1, "updated": 0, "failed": 1,
 "offset": 0, "limit": 100,
 "rows": [{"index": 0, "code": "PLY-001", "status": "created",
           "variants": [{"code": "NEGRO-CH", "sku": "PLY-001-NEGRO-CH"}, {"code": "NEGRO-M", "sku": "PLY-001-NEGRO-M"}]},
          {"index": 1, "code": "PLY-002", "status": "error", "errors": ["category: Objeto con code=NOPE no existe."]}]}
```

  Las filas `created` y `updated` traen `variants`: las variantes que el producto tiene al terminar (`code` y `sku`),
  ordenadas por posición (con `variants_mode: "exact"`, en el orden enviado), para que el conector actualice su mapa sin
  otra llamada; incluye las variantes desactivadas que ya no se listaron. Las filas con error no traen `variants`.

  `rows` se pagina con `?offset=` y `?limit=` (por omisión 100, máximo 500); `?status=error` deja solo las filas con
  error. `index` es la posición en `items`. El trabajo se consulta mientras corre; sus datos se conservan, sin límite
  de tiempo, y los productos enviados se descartan al terminar. Si el worker no está corriendo, el trabajo queda
  `pending` hasta que vuelva. Un trabajo es de la tienda de la llave: con otra llave, `404`.
- `POST /products/bulk/` y `GET /products/bulk/{id}/` ocupan esas rutas: no hay un producto con código `bulk` por la API
  (`GET /products/bulk/` responde `405`).

**Cuánto tarda** (laptop de desarrollo, PostgreSQL local, un solo worker; productos con 3 a 5 variantes y 2 opciones,
enviados en llamadas de 500; la llamada `POST` responde en milisegundos):

| Carga | Antes (#77) | Ahora (#89) |
|---|---|---|
| 10 000 productos, `variants_mode: "exact"` (39 999 variantes) | 14 productos/s (medido con 2 000) | **111 s, 90 productos/s** |
| 100 000 productos, `variants_mode: "exact"` (399 999 variantes) | alrededor de 2 h (extrapolado) | **23,3 min (1 399 s), 71,5 productos/s** |
| 10 000 productos sin el modo exacto (combinaciones) | 11,3 min (679 s), 15 productos/s | 7,2 min (429 s), 23 productos/s |

La meta (100 000 productos en menos de 1 h) se cumple con el modo exacto; sin él, 100 000 tomarían alrededor de
1 h 12 min (extrapolado de 10 000). Las cifras varían con la carga de la máquina (una corrida de 10 000 con otro proceso
pesado en paralelo dio 44 productos/s). Qué se optimizó: en el modo exacto, un producto nuevo inserta sus opciones, valores
y variantes por tandas (unas 16 consultas por producto en vez de unas 70) con el mismo resultado que el `PUT`; el
código y el SKU únicos se calculan en memoria; categorías, marcas, grupos y atributos se consultan una vez por tanda; y
la tanda es una sola transacción (un `commit` por 100 productos). Un producto que ya existe, o sin el modo exacto,
conserva el camino de siempre (el orden de las variantes generadas no cambia). Se procesa con **un solo worker**: dos
trabajos de la misma tienda a la vez podrían chocar al repartir códigos o SKUs (el error saldría en la fila, sin
corrupción), y con una sola CPU de base de datos no ganaría velocidad. Mide con
`RM_BENCHMARK=10000 [RM_BENCHMARK_MODE=exact] python manage.py test apps.api.tests.test_products_bulk.Benchmark`.

### Importación de catálogo (primeras cargas)

Para cargar el catálogo completo de una tienda nueva (decenas o cientos de miles de productos) desde un archivo. A
diferencia de `/products/bulk/` (hasta 500 productos por llamada y upsert), esto **solo crea**: sube un archivo y el
worker lo lee por tandas. Requiere `write_products` (consultar, `read_products`).

```
POST /products/import/   (multipart/form-data)
  file=@catalogo.ndjson

202 {"id": "01a1…", "status": "pending", "total": 100000, "processed": 0, "created": 0, "updated": 0, "failed": 0,
     "created_at": "…", "started_at": null, "finished_at": null}
```

- **Formato: NDJSON** (un objeto JSON por línea, UTF-8; se tolera el BOM y los saltos de línea de Windows). Cada línea tiene el mismo
  formato que el cuerpo de `PUT /products/{code}/` más su `code`. La primera línea se revisa al subir (`400` si no es un
  objeto con `code`, si no viene `file`, si está vacío o si pesa más de 256 MB); el resto, línea por línea en el worker.
  Cada línea puede pesar hasta 1 MB; una más larga queda como error de esa línea.
- **Qué acepta:** productos con `variants_mode: "exact"` (con `options` y `variants`) y productos sin `options` ni
  `variants` (quedan con la variante predeterminada, de SKU igual al código del producto). Una línea con `options` o
  `variants` sin el modo exacto es un error de esa línea: esos productos van por `POST /products/bulk/`. También
  aplican `category`, `brand`, `attributes` (de producto y de variante), `units` y `modifier_groups`.
- **Solo crea.** Si el código ya existe en la tienda (o se repite en el archivo, donde cuenta la primera línea), la línea
  es un error y el producto existente no se toca. Actualizar o reemplazar productos queda para otra función.
- **Mismas validaciones y mensajes que el `PUT`:** una línea inválida da los mismos `errors` que daría el `400` del
  `PUT`, y no queda nada de ella. Una línea mala (JSON roto, línea vacía, código faltante) no detiene las demás.
- **Variantes y unicidad:** el código y el SKU de cada variante se calculan igual que en el `PUT` (los choques agregan
  `-2`, `-3`…), y el `slug` del producto, si no viene, sale del nombre y se hace único igual que en el `PUT`.
- **Fuera de este contrato:** existencias, precios, referencias externas, traducciones e imágenes (se cargan con sus
  propios endpoints después). La plantilla de variantes de la categoría tampoco se aplica (igual que el `PUT`).
  Como el `PUT` y `/products/bulk/`, no emite eventos ni webhooks.
- **Tandas:** el worker procesa 500 líneas por tanda, una transacción por tanda con el avance (`processed` y los bytes
  leídos), así que un reinicio retoma donde quedó sin repetir productos. Si el archivo se pierde antes de terminar, el
  trabajo queda `failed` con un `error`. Un solo worker atiende la importación; dos importaciones de la misma tienda a
  la vez, o una importación y un `PUT` del mismo código, no están coordinadas (la que llegue segunda falla en esa fila).

`GET /products/import/{id}/` trae el mismo cuerpo que el de `/products/bulk/{id}/` (`status` `pending`, `running`,
`done` o `failed`; `updated` es siempre 0; si falló, `error`) y `rows` paginado con `?offset=`, `?limit=` (por omisión
100, máximo 500) y `?status=error`:

```json
{"id": "01a1…", "status": "done", "total": 3, "processed": 3, "created": 2, "updated": 0, "failed": 1,
 "offset": 0, "limit": 100,
 "rows": [{"index": 0, "code": "PLY-001", "status": "created", "variants": [{"code": "NEGRO-CH", "sku": "PLY-001-NEGRO-CH"}]},
          {"index": 1, "code": "PLY-002", "status": "error", "errors": ["code: Ya existe un producto con este código."]}]}
```

`index` es el número de línea (desde 0) y `variants` trae el `code` y `sku` guardados de cada variante, por posición.
Para cientos de miles de filas, `GET /products/import/{id}/rows/` devuelve **todo el resultado en NDJSON** (una fila por
línea, en orden, sin paginar; también acepta `?status=error`). Los resultados por línea se conservan sin límite de
tiempo; el archivo subido se borra al terminar. Un trabajo es de la tienda de la llave: con otra llave, `404`.
`import` es un código reservado: `/products/import/` ya no alcanza a un producto con ese código (`GET` responde `405`).

**Cuánto tarda** (mismas condiciones que la tabla de arriba, productos con 3 a 5 variantes y 2 opciones, modo exacto):

| Carga | Con `/products/bulk/` (#89) | Con la importación (#90) |
|---|---|---|
| 10 000 productos (39 999 variantes) | 111 s, 90 productos/s | **44 s, 227 productos/s** |
| 100 000 productos (399 999 variantes) | 23,3 min (1 399 s) | **8,1 min (484 s), 206,6 productos/s** |

Las cifras de 100 000 se midieron en esta laptop de desarrollo (i5-1135G7). Qué hace más rápido: valida con el mismo serializador, arma los productos en memoria y guarda cada tanda con una
inserción por tabla, en vez de unas decenas de consultas por producto. Mide con
`RM_BENCHMARK=10000 python manage.py test apps.api.tests.test_import_equivalence.Benchmark`.

### Imágenes

```
POST /products/PLY-001/images/   (multipart/form-data)
  image=@foto.jpg
  alt_text=Playera negra de frente
  variant_skus=PLY-001-N-CH,PLY-001-N-M     (opcional)
```

JPG, PNG, WEBP o GIF, hasta 10 MB. Se genera una miniatura WEBP de 400 px. La respuesta trae el `id` (UUID)
de la imagen, que es el que se usa para borrarla.

- **SKU desconocidos:** si algún SKU de `variant_skus` (lista JSON o separados por coma) no es una variante de ese
  producto, responde `400` con `variant_skus` y `unknown_skus` (la lista) y no crea la imagen.
- **Clave externa:** `external_key` (opcional, hasta 100 caracteres) es única por producto. Un `POST` con la misma
  clave **reemplaza** la imagen (conserva `id` y orden; sin `alt_text` o `variant_skus` conserva los anteriores) y
  responde `200`; con una clave nueva responde `201`. Sin clave siempre se crea. Borrar por clave:
  `DELETE /products/{code}/images/?external_key=LS-1` (`204`; `404` si no existe).
- **Imagen por URL:** en lugar de `image`, manda JSON `{"url": "https://cdn.ejemplo.com/a.jpg", "external_key": "LS-1",
  "alt_text": "...", "variant_skus": ["A"]}`. Solo `https`, sin usuario ni contraseña, hasta 500 caracteres; se guarda
  la URL **sin descargarla** (no hay miniatura ni ancho/alto) y la tienda y el back office la muestran directo.
  `url` y `thumbnail_url` de la respuesta son esa URL; `external_url` y `external_key` también se devuelven. Mandar
  `image` y `url` juntos es `400`. Para cambiar una imagen de archivo a URL (o al revés) reenvía con la misma clave.
- Seguridad: la CSP de la tienda y del back office permite `img-src https:` (cualquier https, solo para imágenes;
  scripts, estilos, marcos y formularios siguen restringidos al propio sitio).

### Variantes

`GET /variants/?product=PLY-001` o `?barcode=750...` · `PATCH /variants/{sku}/` con
`sku`, `barcode`, `weight`, `is_active` y `attributes` (`{"CODIGO": valor}` de atributos de `scope` `variant`;
`null` borra el valor; un código desconocido o de producto es `400`).

`DELETE /variants/{sku}/` (`204`) borra la variante con sus precios, existencias e imágenes ligadas (la imagen
se conserva en el producto). `409` si la variante tiene pedidos (en ese caso desactívala con `is_active: false`: los
pedidos conservan SKU y nombre, pero se prefiere no perder el vínculo) o si es la única del producto (para eso, borra
el producto). Si el producto tiene opciones, la combinación reaparece cuando se vuelven a guardar sus opciones; para
quitarla de verdad, cambia las opciones. Pide `write_products`.

### Precios en bloque

```json
POST /prices/bulk/
{"items": [
  {"price_list": "GENERAL", "sku": "PLY-001-N-CH", "price": "249.00", "compare_at_price": "299.00"},
  {"price_list": "GENERAL", "sku": "PLY-001-N-CH", "price": "220.00", "min_quantity": "6"},
  {"price_list": "MAYOREO", "sku": "PLY-001-N-M", "price": null}
]}
```

- Hasta 1000 renglones por llamada. Por omisión se aplica todo o nada.
- **Éxito parcial** (opcional): `POST /prices/bulk/?partial=true` (o `"partial": true` en el cuerpo) aplica las filas
  válidas y reporta las demás; ver «Éxito parcial en los bloques».
- `price: null` borra esa línea.
- `min_quantity` (por defecto `0`) equivale a "Cantidad mínima" de las líneas de precio de BC.
- Respuesta: `{"updated": 2, "deleted": 1}`. Si hay errores, `400` con
  `{"errors": {"<índice>": ["..."]}}`.

`GET /prices/?price_list=GENERAL&sku=...&product=...`

### Inventario en bloque

```json
POST /inventory/bulk/
{"items": [
  {"sku": "PLY-001-N-CH", "location": "CEDIS", "on_hand": "12"},
  {"sku": "PLY-001-N-CH", "location": "TDA-CENTRO", "on_hand": "3", "reference": "BC 2026-10-14"}
]}
```

- `on_hand` es la existencia **absoluta** (como la calcula BC), no un ajuste.
- Cada cambio queda en la bitácora de movimientos con motivo "Sincronización".
- Por omisión se aplica todo o nada, igual que los precios; `?partial=true` activa el éxito parcial.
- Respuesta: `{"received": 2, "changed": 1}`.

`GET /inventory/?sku=...&location=...&product=...` devuelve `on_hand`, `reserved` y `available`
(`available = on_hand − reserved`; el back office muestra también el disponible junto a la existencia).

### Inventario con ERP

Un solo flujo, con o sin ERP: cada pedido aparta su cantidad al crearse y al enviar (`ship`, `ready`) se libera el apartado y se descuenta `on_hand` (movimiento «Venta»); cancelar o vencer sin pago libera el apartado.
`POST /inventory/bulk/` sobrescribe `on_hand` con el valor absoluto del ERP y no toca `reserved`: su número manda. Mientras un pedido sigue abierto en ambos sistemas, RM muestra menos que el ERP (nunca más).
`POST /orders/{id}/prepare/` y la referencia externa del pedido por la conexión del conector solo dejan `erp_accepted_at` (informativo, `null` mientras no ocurra) y un evento «El ERP recibió el pedido.»; no liberan ni descuentan nada.

### Éxito parcial en los bloques

`POST /prices/bulk/?partial=true` y `POST /inventory/bulk/?partial=true` (también `"partial": true` en el cuerpo)
validan fila por fila: aplican las válidas, en una sola transacción, y no rechazan el lote por las malas (SKU, lista o
ubicación inexistentes, valores inválidos). Siempre responde `200` (aunque todas fallen) con los totales de siempre más
`failed` y `rows`, un renglón por cada fila enviada, en el mismo orden:

```json
{"updated": 1, "deleted": 0, "failed": 1,
 "rows": [{"index": 0, "status": "updated"},
          {"index": 1, "status": "error", "errors": ["No existe el SKU «NOPE»."]}]}
```

`status`: `updated` o `deleted` (precios); `changed` o `unchanged` (inventario); `error`. En inventario `received` cuenta
las filas aplicadas. Sin `partial` (o con `false`) el comportamiento no cambia: `400` con `{"errors": {...}}` y no se
aplica nada. El límite de 1000 filas sigue valiendo.

### Corridas de sincronización

El conector reporta cada corrida para que se vea en RM cuándo sincronizó y cómo le fue. Pide `write_integrations`
(`read_integrations` para listar). `{id}` es el `id` (UUID) de la conexión.

```json
POST /connections/{id}/sync-runs/
{"kind": "inventory", "started_at": "2026-10-17T10:00:00Z", "finished_at": "2026-10-17T10:05:00Z",
 "records": 1200, "errors": 3, "message": "3 SKU sin variante"}
```

- `kind` (texto libre, hasta 30: `products`, `prices`, `inventory`, `images`...), `started_at` y `finished_at`
  (`finished_at` no puede ser anterior) son obligatorios; `records` y `errors` (enteros, por omisión `0`) y `message`
  (hasta 2000) son opcionales.
- `status` (opcional): `ok`, `partial` o `failed`. Sin él: `ok` si `errors` es `0`, `partial` si no.
- Responde `201` con la corrida (`id`, `kind`, `started_at`, `finished_at`, `records`, `errors`, `status`, `message`).
  Actualiza `last_sync_at` de la conexión a `finished_at` (nunca lo retrocede; una corrida `failed` no lo mueve).
- `GET /connections/{id}/sync-runs/` lista las corridas (más antiguas primero, por cursor; filtro `?kind=`).
- La ficha de la app «LS Central – conector» en el back office muestra las últimas 20.

### Referencias externas

Guardan la relación entre un registro local y su ID en BC (u otro sistema), por conexión.
Las conexiones se crean en el back office (**Configuración → Integraciones**) y su `id` se
consulta con `GET /connections/`.

```json
POST /external-references/
{"connection": "0192f3a4-6b1c-7d2e-9f00-1a2b3c4d5e6f", "object_type": "product", "object_key": "PLY-001",
 "external_id": "5c2e...-guid-de-BC", "external_code": "1000", "data_hash": "..."}
```

- `object_type`: `product`, `variant` (la llave es el SKU), `category`, `attribute`,
  `location`, `price_list`, `customer_group`, `customer` (la llave es el código del cliente), `order` (la llave es el número).
- Con `object_type: "order"`, si la conexión es la del conector, registrar la
  referencia deja el sello informativo `erp_accepted_at` del pedido (ver «Inventario con ERP»).
- Es un upsert por (conexión, registro local). Un mismo `external_id` no puede quedar ligado a
  dos registros.
- `GET /external-references/?connection={id}&object_type=product&external_id=...` para buscar.
- Cada referencia trae su `id` (UUID), que se usa para borrarla.
- Al borrar el registro local se borran sus referencias.

## Webhooks (eventos hacia tu sistema)

La app **Webhooks** de la tienda manda un `POST` con JSON a la dirección configurada por cada evento elegido
(`order.created`, `order.updated`, `order.paid`, `order.payment_failed`, `order.shipped`, `order.ready_for_pickup`, `order.delivered`, `order.cancelled`,
`order.returned`, `order.refunded`, `inventory.changed`). No se siguen redirecciones; espera de 5 s. Solo se aceptan direcciones `https`
y públicas: nunca se avisa a redes internas (localhost, rangos privados ni direcciones locales).

Cuerpo:

```json
{"id": "6f1c…", "event": "order.created", "store": "mia", "occurred_at": "2026-10-15T20:31:02+00:00", "data": {…}}
```

Cabeceras: `X-RM-Event` (nombre), `X-RM-Event-Id` (igual a `id`), `X-RM-Signature` (HMAC-SHA256 en hexadecimal del
cuerpo exacto, con el secreto de la app) y `User-Agent: RM-Webhooks/1`.

- **Entrega y reintentos:** responde `2xx` para confirmar. Con `5xx`, `429`, `408`, tiempo agotado o error de red se
  reintenta con espera creciente (30 s · 2^n, máximo 1 h, con variación aleatoria), hasta 8 intentos en unas 2 horas.
  Cualquier otro `4xx` (y las redirecciones) se da por fallido sin reintentar. Cada reintento manda **el mismo
  cuerpo, el mismo `id` y la misma firma**.
- **Al menos una vez:** una entrega puede llegar repetida. Deduplica por `id`.
- **Datos:** los eventos de pedido traen el `id` (UUID), el `number`, los tres estados, `delivery_type`,
  `payment_method` y las líneas (con `id` y `parent_line`); `inventory.changed` trae `sku`,
  `product_code`, `product_name` y `available`.
- **`order.refunded`:** un evento por reembolso hecho (también al cancelar o devolver un pedido pagado). Trae los
  datos del pedido (con `refunded_total`) y `refund` con `id`, `payment_id`, `amount`, `currency`, `status` y
  `reason`. Un reembolso de un pago `needs_review` no lo emite.
- **Orden:** no está garantizado. Compara `occurred_at`; en `inventory.changed` ignora el que sea más viejo que el
  último aplicado para ese SKU. Los cambios seguidos de existencia de una variante se agrupan en un solo evento con
  la existencia final (`data.available`).
- **Desactivación:** tras 5 entregas fallidas seguidas la app se pausa y la tienda ve un aviso en Inicio. Una entrega
  exitosa reinicia la cuenta; al reactivar la app o guardar su configuración también.

## Ejemplo con curl

```bash
export RM_BASE_URL="https://retail-s.online/api/v1"   # local: http://127.0.0.1:8000/api/v1
export RM_API_KEY="rk_..."                              # tu llave; nunca en el código ni en un repositorio
curl -s "$RM_BASE_URL/" -H "Authorization: Bearer $RM_API_KEY"
curl -s -X PUT "$RM_BASE_URL/products/TAZA-01/" \
  -H "Authorization: Bearer $RM_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Taza", "status": "active"}'
# En el servidor de pruebas de la LAN con CA propia, agrega: --cacert casa-ca.crt
```

## Límites

Cada llave de API tiene su propio presupuesto: **600 peticiones por minuto** (ventana deslizante; el operador lo cambia
con `API_RATE_LIMIT_PER_MINUTE`). La llave de una tienda nunca gasta el presupuesto de otra, ni el de otra llave de la
misma tienda. Cuentan todas las peticiones autenticadas y con permiso, también las que terminan en error de validación.

Cada respuesta autenticada (también la `429`) trae:

- `X-RateLimit-Limit`: peticiones permitidas por minuto.
- `X-RateLimit-Remaining`: las que quedan en la ventana actual.
- `X-RateLimit-Reset`: segundo (epoch Unix) en que se libera el cupo más antiguo de la ventana.

Al agotarlo, la API responde `429` con `Retry-After` (segundos que faltan) y `X-RateLimit-Remaining: 0`. Las peticiones
rechazadas no cuentan, así que esperar el `Retry-After` basta. Las respuestas `401` y `403` no llevan estas cabeceras.

## Cambios de contrato

- **28 oct 2026 (#171): documentación en inglés.** Sin cambios en la API. `/docs/?lang=en`, `/llms.txt?lang=en`, `docs/openapi.en.json`, la colección de Postman en inglés, `docs/api.en.md` y `docs/api-ia.en.md`.
- **28 oct 2026 (#166): documentación.** Sin cambios en la API. Nuevos: `docs/openapi.json` (OpenAPI 3.1), colección y environment de Postman, página pública `/docs/`, `/llms.txt` y `docs/api-ia.md`. La base pasa a `{base_url}` (ver arriba).
- **20 oct 2026 (#105): un solo flujo de inventario.** `erp_accepted_at` pasa a ser solo informativo: `POST /orders/{id}/prepare/` y la referencia externa del pedido ya no liberan el apartado, el pedido sigue apartando y se descuenta al enviar o dejarlo listo, y un pedido con `erp_accepted_at` se puede seguir editando y vence igual que los demás. Se retira la opción «LS Central manda el inventario». `POST /orders/{id}/prepare/` por la API deja el sello `erp_accepted_at` y el evento en toda tienda, con o sin conector (con #88 no sellaba sin la opción). Ver «Inventario con ERP».
- **19 oct 2026 (#14): límite de peticiones.** Cada llave de API tiene 600 peticiones por minuto; las respuestas
  autenticadas traen `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`, y al agotarlo la API responde
  `429` con `Retry-After`. Ver «Límites».
- **19 oct 2026 (#12): reintentos seguros.** Cabecera opcional `Idempotency-Key` en `POST /orders/`,
  `POST /orders/{id}/pay/` y `POST /orders/{id}/returns/` (ver «Pedidos»): `201` la primera vez y `200` al repetir
  (`pay` responde `200` siempre), `409` con la misma llave y otros datos. Sigue el mismo criterio que los
  reembolsos (#60): la llave no caduca y la repetición no guarda la respuesta original; por eso el contrato difiere
  del texto del issue (`422` y 24 horas). Sin campos nuevos en las respuestas ni en los eventos. Sin la cabecera todo
  sigue igual.
- **19 oct 2026 (#60): reembolsos.** `POST /orders/{id}/refunds/` (con `Idempotency-Key` opcional; permiso
  `write_orders`), campo nuevo `refunds` en cada pago de `GET /orders/{id}/payments/`, evento nuevo `order.refunded` y
  `refunded_total` en los datos de los eventos de pedido. Cancelar un pedido pagado (también parcialmente
  reembolsado) o registrar una devolución ahora reembolsa por el pago del pedido. Sin cambios en las demás rutas.
- **19 oct 2026 (#90): importación de catálogo.** `POST /products/import/` (archivo NDJSON, `202` con el id de un trabajo
  asíncrono que solo crea productos), `GET /products/import/{id}/` (estado, avance y resultado por línea) y
  `GET /products/import/{id}/rows/` (el resultado completo en NDJSON). `import` pasa a ser un código reservado en
  `/products/import/`. Sin cambios en las rutas existentes.
- **17 oct 2026 (#89): variantes en el resultado de la alta masiva.** Campo nuevo `variants` (`[{"code", "sku"}]`, por
  posición) en las filas `created` y `updated` de `GET /products/bulk/{id}/`; el resto de la fila no cambia. El
  procesamiento es más rápido (ver «Cuánto tarda») y cada tanda de 100 productos es una transacción.
- **17 oct 2026 (#68): variantes exactas.** `PUT /products/{code}/` (y `POST /products/bulk/`) acepta `variants_mode: "exact"`:
  el producto queda solo con las variantes de `variants`, identificadas por `options` y con el `code` indicado, sin
  generar las demás combinaciones. `code` pasa a ser opcional en ese modo; `options` dentro de `variants` solo se acepta
  con él. Sin `variants_mode` nada cambia.
- **17 oct 2026 (#88): inventario con ERP.** Campo nuevo de lectura `erp_accepted_at` en el pedido. En una tienda con la
  opción «LS Central manda el inventario», `POST /orders/{id}/prepare/` y la referencia externa del pedido por la conexión
  del conector liberan el apartado de ese pedido (ver «Inventario con ERP»). Sin esa opción nada cambia.
- **17 oct 2026 (#52): color de los valores de opción.** `options[].values` del `PUT /products/{code}/` acepta, además de
  textos, `{"value", "color"}` (hexadecimal `#RRGGBB`). Campos nuevos de lectura: `options[].colors` en el producto y
  `option_colors` en cada variante (también en `/variants/`). El formato de `values` como textos no cambia.
- **17 oct 2026 (#77): alta masiva de productos.** `POST /products/bulk/` (hasta 500 productos, respuesta `202` con el id de un
  trabajo asíncrono) y `GET /products/bulk/{id}/` (estado, avance y resultado por fila). Un producto con código `bulk`
  ya no se alcanza por `/products/bulk/`.
- **15 oct 2026 (#20, ADR 0002): IDs públicos.** Clientes, pedidos (y sus líneas y devoluciones), imágenes, conexiones
  y referencias externas usan como `id` un UUID en lugar del número interno, en las respuestas, en las rutas
  (`/customers/{id}/`, `/orders/{id}/`, `/connections/{id}/`, `/external-references/{id}/`,
  `/products/{code}/images/{id}/`) y en los campos que los referencian (`customer`, `connection`, `line_id`).
  Los pedidos ya no se buscan por número en la ruta: usa `?number=`. Los recursos con código (productos, categorías,
  ubicaciones, etc.) ya no traen `id`. `GET /` ya no trae el id de la tienda. En `inventory.changed`, `product_id`
  pasó a `product_code`.
- **15 oct 2026 (#21, ADR 0007): scopes.** Cada endpoint exige un permiso de la llave (ver «Permisos»); sin él
  responde `403`. Las llaves que ya existían conservan todos los permisos. `GET /` trae `scopes`.
- **15 oct 2026 (#53, ADR 0008): paginación por cursor** en todos los listados, en lugar de `limit`/`offset`.
  La respuesta ya no trae `count` ni `previous`; los pedidos se listan en orden de alta (antes por número).
- **15 oct 2026 (#10): estados del pedido.** Valores nuevos: `status` `archived`; `payment_status` `authorized` y
  `failed`; `fulfillment_status` `ready_for_pickup`. Campos nuevos: `delivery_type`, `archived_at`,
  `reservation_expires_at` y `parent_line` en las líneas. `payment_method` pasa a ser una clave (`card`, `transfer`,
  `cash_on_delivery`, `in_store`, `other`), también en `pay`. Acciones nuevas: `ready`, `archive`, `unarchive`. Evento
  nuevo: `order.ready_for_pickup`. Un pedido `pickup` ya no se marca como `shipped`.
- **15 oct 2026 (#9): pagos.** Recurso de solo lectura `GET /orders/{id}/payments/`. Evento nuevo
  `order.payment_failed`. Un pedido de la tienda pagado con tarjeta nace `pending` y pasa a `paid` cuando la
  pasarela lo confirma.
- **17 oct 2026 (#75): medios de pago por tienda.** Recurso de solo lectura `/payment-methods/`. `payment_method` ya
  no es una lista fija: es el `code` de un medio de la tienda (los códigos de siempre siguen siendo válidos). Campo
  nuevo `payment_method_name` en el pedido. `reservation_expires_at` ahora se fija según el medio y el pedido sin
  pagar se cancela al vencer. `cancel_reason` también va en el evento `order.cancelled`.
- **17 oct 2026 (#78): atributos por variante.** Campo `scope` (`product` o `variant`, por defecto `product`) en
  `/attributes/`; `attributes` en cada variante (lectura, `variants[]` del `PUT /products/{code}/` y
  `PATCH /variants/{sku}/`). Los atributos de producto siguen igual; enviar un código de variante a nivel producto
  (o al revés) es `400`.
- **17 oct 2026 (#76): paqueterías y servicios.** Recurso de solo lectura `/carriers/`. Campos nuevos en el pedido:
  `carrier_code`, `carrier_service_code`, `carrier_service_name`, `carrier_fee` y `tracking_url`. `POST /orders/` acepta
  `carrier_code` y `carrier_service_code`; `ship` acepta `carrier_code`. Los eventos de pedido traen los datos de envío.
- **15 oct 2026 (#54): modificadores.** Recurso nuevo `/modifier-groups/`, campo `modifier_groups` en productos,
  `modifiers` en las líneas al crear pedidos y líneas hijas (`parent_line`) en las respuestas.
- **15 oct 2026 (#55): horarios.** Campo `hours` en ubicaciones; `POST /orders/` puede responder `400` fuera de horario.
- **15 oct 2026 (#56): tipos de pedido de restaurante.** `takeout` y `dine_in` en `delivery_type` (según la tienda) y
  campo `table_number`.
