diff --git a/app/poros/orders/process_webhook_order.rb b/app/poros/orders/process_webhook_order.rb index 49a79b5..adb5106 100644 --- a/app/poros/orders/process_webhook_order.rb +++ b/app/poros/orders/process_webhook_order.rb @@ -21,6 +21,7 @@ class UnmappedProductError < StandardError; end MISSING_ITEMS = 'the payload does not carry any order item' UNREADABLE_ITEMS = 'the template could not read %d of the order items in the payload' ORDERS_UNIQUE_INDEX = 'index_orders_on_company_id_and_external_order_id' + CANCELLED = 'cancelled' def initialize(webhook_log:) super() @@ -82,11 +83,26 @@ def create_order(items) # transacción, así que el orden no abre ninguna ventana. def register_item(order, item, mapping) quantity = quantity_of(item) - stock = Catalog::DeductStock.new(product: mapping.product, quantity: quantity).call - OrderItem.create!(order: order, product: mapping.product, warehouse_id: stock.warehouse_id, + OrderItem.create!(order: order, product: mapping.product, + warehouse_id: take_units(order, mapping.product, quantity), quantity: quantity, unit_price: unit_price_of(item, mapping)) end + # Una venta que llega ya cancelada (en Mercado Libre la primera notificación + # puede traer un pago rechazado) se registra —queda el rastro de que existió— + # pero no se lleva stock: no va a salir. Antes se descontaba igual, y como una + # orden cancelada no se edita ni se vuelve a cancelar, esas unidades no + # volvían nunca y los canales publicaban de menos. + # + # Sin descuento no hay depósito que registrar: la línea queda sin él, igual + # que las anteriores a TESIS-126, y como la orden ya está cancelada nada va a + # intentar devolverle unidades. + def take_units(order, product, quantity) + return if order.status == CANCELLED + + Catalog::DeductStock.new(product: product, quantity: quantity).call.warehouse_id + end + # Resuelve el producto interno de cada ítem antes de escribir nada: un ítem # sin mapear corta el procesamiento acá, no a mitad de la orden. # diff --git a/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md b/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md index 5579604..59910c7 100644 --- a/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md +++ b/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md @@ -89,6 +89,7 @@ Una venta que entra mal es peor que una venta que no entra: queda escrita, con m | `unit_price` en el payload **y** `external_price` en el mapping (los dos) | Corta | Un ítem en 0 es indistinguible de una bonificación legítima: el error queda enterrado en un registro financiero y ya no se puede detectar | | Un id externo sin `ProductMapping` | Corta (`UnmappedProductError`) | Es el caso que la DLQ resuelve sola: se crea el mapeo y se reintenta | | `status` ausente o desconocido para el OMS | **No corta**: entra como `pending` | El estado es informativo y se corrige después; la venta es el dato que no se puede perder | +| `status` que llega `cancelled` | **No corta**: se registra cancelada, **sin descontar stock** y con las líneas sin depósito | La venta no va a salir. Descontar dejaba las unidades fuera para siempre, porque una orden cancelada no se edita ni se vuelve a cancelar (TESIS-999016) | | Datos del comprador (documento, dirección, CP) | **No corta** | La plantilla los mapea si el canal los manda; su ausencia no mueve ni stock ni dinero | El precio tiene un respaldo antes de cortar: el `external_price` del `ProductMapping`, o sea el precio publicado en ese canal. Es un precio real de la venta y no uno inventado, y cubre el caso común de un canal que no manda el precio en el webhook. Un `0` que **sí** viene en el payload se respeta: ahí el canal está afirmando que el ítem fue bonificado. diff --git a/spec/poros/orders/process_webhook_order_spec.rb b/spec/poros/orders/process_webhook_order_spec.rb index 738d20d..ab7c326 100644 --- a/spec/poros/orders/process_webhook_order_spec.rb +++ b/spec/poros/orders/process_webhook_order_spec.rb @@ -379,6 +379,61 @@ def unique_violation end end + # La primera notificación de una venta puede traerla ya cancelada (en ML, un + # pago rechazado). Antes descontaba igual, y como una orden cancelada no se + # edita ni se vuelve a cancelar, esas unidades no volvían nunca. + context 'when the sale arrives already cancelled' do + subject(:process) { described_class.new(webhook_log: cancelled_log) } + + def cancelled_log + @cancelled_log ||= create_log(order_payload(items: [line('MLA-1', 2, 100)], + status: 'cancelado')) + end + + def create_service(mapper: order_mapper) + Service.create!(service_name: "Mercado Libre #{SecureRandom.hex(4)}", type: 'ecommerce', + http_method: 'GET', uri: 'https://api.ml.test/orders', + response_mapper: mapper, + response_value_mapper: { 'pagado' => 'paid', 'cancelado' => 'cancelled' }) + end + + before { publish('SKU-1', 'MLA-1', stock: 20) } + + it 'records the sale as cancelled, so it leaves a trace', :aggregate_failures do + order = process.call + + expect(order.status).to eq('cancelled') + expect(order.order_items.sum(:quantity)).to eq(2) + end + + it 'takes no units from the stock' do + process.call + + expect(stock_of('SKU-1')).to eq(20) + end + + it 'marks the log as processed' do + process.call + + expect(cancelled_log.reload.status).to eq('processed') + end + + # Ya cancelada, nada va a intentar devolverle unidades. + it 'leaves its lines without a warehouse, since nothing was taken from any' do + expect(process.call.order_items.pluck(:warehouse_id)).to eq([nil]) + end + end + + context 'when the sale arrives paid' do + before { publish('SKU-1', 'MLA-1', stock: 20) } + + it 'still takes its units from the stock' do + described_class.new(webhook_log: create_log(order_payload(items: [line('MLA-1', 2, 100)]))).call + + expect(stock_of('SKU-1')).to eq(18) + end + end + # La ciudad y la provincia las necesita el courier para cotizar el envío # (TESIS-128). La provincia se valida contra Order::PROVINCES. describe 'the city and the province of the customer' do