Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/avo/resources/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def fields
# consulta periódica pregunta por sus envíos (TESIS-49).
field :tracking_service, as: :belongs_to, use_resource: Avo::Resources::Service,
name: 'Tracking template', only_on: %i[show forms]
# En la plantilla que despacha: la que le pide tarifas al mismo
# proveedor. Sin ella, el courier no se ofrece al cotizar (TESIS-131).
field :quote_service, as: :belongs_to, use_resource: Avo::Resources::Service,
name: 'Quote template', only_on: %i[show forms]

mapper_fields
end
Expand Down
116 changes: 116 additions & 0 deletions app/controllers/api/v1/draft_quotes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

module Api
module V1
# Cotización de un alta que todavía no se confirmó (TESIS-131).
#
# La cotización de TESIS-46 cuelga de una orden, y crear la orden descuenta
# el stock. El paso 3 del alta manual (TESIS-59) necesita mostrar las tarifas
# ANTES de que el operador confirme, así que acá se cotiza el paquete con lo
# que el asistente ya juntó: depósito de origen, destino y líneas. La orden se
# crea una sola vez, cuando el operador elige y confirma.
class DraftQuotesController < ApplicationController
# El parámetro que falta es un 400 de contrato, con el mismo cuerpo
# `{ error }` que el resto de la API.
rescue_from ActionController::ParameterMissing, with: :render_bad_request

# Es el primer endpoint autenticado que sale a los couriers sin dejar nada
# en la base: antes cotizar exigía crear la orden, que era un freno natural.
# Sin tope, un cliente en loop —un `useEffect` mal puesto que cotiza en
# cada tecla— genera tantas llamadas a los proveedores como quiera, con las
# credenciales de la empresa. Se cuenta por usuario y no por IP: todos los
# requests llegan autenticados, y un depósito detrás de un mismo NAT no
# debería compartir el cupo. El asistente cotiza al entrar al paso 3 y en
# cada reintento, así que 20 por minuto sobra para el uso normal.
QUOTES_PER_WINDOW = 20
QUOTE_WINDOW = 1.minute

rate_limit to: QUOTES_PER_WINDOW, within: QUOTE_WINDOW, only: :create,
by: -> { current_user.id }, with: :render_too_many_quotes

def create
# Cotizar un borrador es el paso previo a darlo de alta: se autoriza como
# crear una orden. Cada request va a los couriers con las credenciales de
# la empresa, pero no lee ni toca nada que no sea del propio tenant.
authorize Order, :create?

quotes = Shipments::QuoteShipment.new(
origin_warehouse: origin_warehouse, destination: destination, lines: lines
).call

# 200 y una lista vacía cuando nadie contestó, igual que la cotización de
# una orden: el front distingue «sin opciones» de «falló la cotización».
render json: { data: quotes }, status: :ok
end

private

def quote_params
params.expect(quote: [:origin_warehouse_id, :destination_zip_code, :destination_address,
{ items: [%i[product_id quantity]] }])
end

# find y no find_by: Warehouse es CompanyScoped, así que un id de otra
# empresa levanta RecordNotFound -> 404 en vez de revelar que existe.
def origin_warehouse
Warehouse.find(required(:origin_warehouse_id))
end

# El código postal es lo que cotizan todas las plantillas: sin él no hay
# tarifa posible. La dirección viaja si está, porque algunas la piden.
def destination
{ zip_code: required(:destination_zip_code).to_s.strip,
address: quote_params[:destination_address].to_s.strip.presence }
end

# Pares [producto, cantidad]. Los productos se buscan dentro del tenant: uno
# ajeno no aparece y responde 404, como el resto de la API.
def lines
products = Product.where(id: items.map(&:first)).index_by(&:id)
items.map do |product_id, quantity|
[products.fetch(product_id) { raise ActiveRecord::RecordNotFound }, quantity]
end
end

# El mismo tope que el alta (OrdersController::MAX_ITEMS): cotizar algo que
# después no se podría crear no le sirve a nadie.
def items
@items ||= begin
raw = quote_params[:items]
raise ActionController::ParameterMissing, :items if raw.blank?

limit = OrdersController::MAX_ITEMS
raise MalformedParameterError, "items exceeds maximum of #{limit}" if raw.size > limit

raw.map do |item|
[positive_integer(item, :product_id), positive_integer(item, :quantity)]
end
end
end

# «Bien formado» acá es un entero positivo. Un 0, un negativo o un texto
# no son un producto ni una cantidad que se pueda enviar.
def positive_integer(item, key)
value = Integer(item[key].to_s, exception: false)
return value if value&.positive?

raise MalformedParameterError, "each item needs a positive integer #{key}"
end

def render_too_many_quotes
response.set_header('Retry-After', QUOTE_WINDOW.to_i.to_s)
render json: { error: 'Too many quotes, try again later' }, status: :too_many_requests
end

# `expect` cubre la clave ausente, no el valor vacío: sin esto un id en
# blanco llegaba a `find('')` y salía como 404, diciéndole al cliente que el
# recurso no existe cuando lo que falta es el parámetro.
def required(name)
value = quote_params[name]
raise ActionController::ParameterMissing, name if value.blank?

value
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/api/v1/shipment_quotes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def create
order = Order.find(params.expect(:order_id))
authorize order, :quote?

quotes = Shipments::QuoteShipment.new(
quotes = Shipments::QuoteShipment.for_order(
order: order, origin_warehouse: origin_warehouse
).call

Expand Down
17 changes: 15 additions & 2 deletions app/controllers/api/v1/shipments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ShipmentsController < ApplicationController
rescue_from Shipments::AlreadyDispatchedError, with: :render_conflict
rescue_from Shipments::InvalidCourierIntegrationError, with: :render_unprocessable
rescue_from Shipments::DispatchResponseError, with: :render_bad_gateway
rescue_from Shipments::InvalidShippingCostError, with: :render_bad_request
rescue_from Integrations::AdapterExecutionError, with: :render_courier_failure
# El parámetro que falta es un 400 de contrato, no un 422 de negocio.
rescue_from ActionController::ParameterMissing, with: :render_bad_request
Expand Down Expand Up @@ -68,7 +69,7 @@ def confirm

dispatched = Shipments::ConfirmDispatch.new(
shipment: shipment, company_integration: courier_integration,
origin_warehouse: origin_warehouse
origin_warehouse: origin_warehouse, shipping_cost: shipping_cost
).call

render json: ShipmentSerializer.render(dispatched), status: :ok
Expand Down Expand Up @@ -118,7 +119,19 @@ def origin_warehouse
end

def dispatch_params
params.expect(dispatch: %i[company_integration_id origin_warehouse_id])
params.expect(dispatch: %i[company_integration_id origin_warehouse_id shipping_cost])
end

# El costo de la opción que el operador confirmó al cotizar (TESIS-131).
# Opcional: sin él, el despacho funciona como antes. Acá sólo se lee como
# número; el rango lo valida ConfirmDispatch contra el modelo, antes de
# llamar al courier, para que la regla viva en un solo lugar.
def shipping_cost
raw = dispatch_params[:shipping_cost]
return nil if raw.blank?

BigDecimal(raw.to_s, exception: false) ||
raise(MalformedParameterError, 'shipping_cost must be a number')
end

# `expect` cubre la clave ausente, no el valor vacío: sin esto un id en
Expand Down
35 changes: 35 additions & 0 deletions app/models/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ class Service < ApplicationRecord
has_many :tracked_services, class_name: 'Service', foreign_key: :tracking_service_id,
inverse_of: :tracking_service, dependent: :nullify

# Plantilla con la que se le piden tarifas a este courier (TESIS-131). Cuelga
# de la plantilla que despacha, igual que la de seguimiento: es lo que permite
# pasar de una opción cotizada a su despacho, porque la cotización la contesta
# una plantilla y la etiqueta la emite otra.
belongs_to :quote_service, class_name: 'Service', optional: true,
inverse_of: :quoted_services
has_many :quoted_services, class_name: 'Service', foreign_key: :quote_service_id,
inverse_of: :quote_service, dependent: :nullify

validates :service_name, presence: true, uniqueness: true
validates :uri, presence: true
validates :http_method, presence: true
validates :type, presence: true, inclusion: { in: TYPES }
validate :mappers_are_valid_json
validate :tracking_service_answers_tracking
validate :quote_service_quotes_shipping

# Sólo los canales de e-commerce generan ventas: el gateway lo usa para decidir
# si un webhook entrante va al procesador de órdenes (TESIS-43) o queda a la
Expand Down Expand Up @@ -146,4 +156,29 @@ def tracking_service_problem

'no es una plantilla de consulta de tracking' unless tracking_service.answers_tracking?
end

def quote_service_quotes_shipping
reason = quote_service_problem
errors.add(:quote_service, reason) if reason
end

# Las dos últimas reglas sostienen lo que la cotización asume: cada opción
# cotizada se despacha con UNA integración (QuoteShipment#dispatchers indexa
# por plantilla de cotización). Si dos plantillas de despacho compartieran el
# cotizador, una de las dos desaparecía de las opciones sin aviso; y una
# plantilla que no despacha con cotizador cargado es una configuración que no
# hace nada. El índice único de `quote_service_id` lo respalda en la base.
def quote_service_problem
return if quote_service.nil?
return 'solo aplica a couriers' unless courier?
return 'no puede ser la misma plantilla' if quote_service == self
return 'solo aplica a la plantilla con la que el courier despacha' unless dispatches_shipment?
return 'no es una plantilla de cotización' unless quote_service.quotes_shipping?

'ya es la plantilla de cotización de otro courier' if quote_service_taken?
end

def quote_service_taken?
self.class.where(quote_service_id: quote_service_id).where.not(id: id).exists?
end
end
9 changes: 8 additions & 1 deletion app/models/shipment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class Shipment < ApplicationRecord
# cuota del proveedor.
IN_FLIGHT_STATUSES = %w[ready_to_ship in_transit].freeze

# Tope de `shipping_cost`: la columna es decimal(10,2), así que lo más grande
# que entra es 99.999.999,99. Validarlo en el modelo hace que un costo fuera de
# rango sea un error de validación y no un RangeError de la base; el despacho
# lo prueba contra esta regla antes de pedir la etiqueta (TESIS-131).
MAX_SHIPPING_COST = 100_000_000

belongs_to :company
# La integración se asigna al inicializar el envío y puede no existir todavía
# (se completa al confirmar el despacho con un courier).
Expand All @@ -26,7 +32,8 @@ class Shipment < ApplicationRecord
scope :in_flight, -> { where(status: IN_FLIGHT_STATUSES).where.not(tracking_number: nil) }

validates :status, presence: true, inclusion: { in: STATUSES }
validates :shipping_cost, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :shipping_cost, numericality: { greater_than_or_equal_to: 0,
less_than: MAX_SHIPPING_COST }, allow_nil: true
# Restricción 1 a 1 de la card: una orden no puede tener dos envíos. El índice
# único sobre order_id (migración) es la garantía a nivel motor; la validación
# del modelo da un mensaje de error limpio antes de llegar a la DB.
Expand Down
33 changes: 31 additions & 2 deletions app/poros/shipments/confirm_dispatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,21 @@ class ConfirmDispatch < ApplicationPoro
# es NOT NULL y es lo que la pantalla muestra como lo que pasó (TESIS-60).
INITIAL_EXTERNAL_STATUS = 'Etiqueta generada'

def initialize(shipment:, company_integration:, origin_warehouse:)
# `shipping_cost` es el de la opción que el operador confirmó al cotizar
# (TESIS-131). Es opcional: un despacho que no lo trae deja el costo como
# estaba.
def initialize(shipment:, company_integration:, origin_warehouse:, shipping_cost: nil)
super()
@shipment = shipment
@integration = company_integration
@origin = origin_warehouse
@shipping_cost = shipping_cost
end

def call
validate_integration!
validate_status!(@shipment)
validate_cost!

parsed = request_label
persist(parsed)
Expand All @@ -62,6 +67,23 @@ def validate_status!(shipment)
raise AlreadyDispatchedError.new(shipment: shipment)
end

# El costo se prueba contra la regla del modelo —la misma que aplica el
# `update!` de `persist`— y no contra una copia: si la columna cambia, la
# validación la sigue. Sin esto, un costo que el modelo rechaza (fuera de
# rango, NaN, infinito) pasaba, se pedía la etiqueta y el `update!` fallaba
# después: el envío seguía en `pending` y un reintento emitía otra etiqueta.
#
# Se valida un envío nuevo con sólo el costo para no tocar `@shipment` antes
# de la llamada externa; del resultado se lee únicamente `shipping_cost`.
def validate_cost!
return if @shipping_cost.nil?

probe = Shipment.new(shipping_cost: @shipping_cost)
probe.validate
reasons = probe.errors.messages_for(:shipping_cost)
raise InvalidShippingCostError, reasons if reasons.any?
end

def validate_integration!
reason = integration_problem
return if reason.nil?
Expand Down Expand Up @@ -140,11 +162,18 @@ def persist(parsed)
@shipment.update!(company_integration: @integration,
tracking_number: tracking_number!(parsed),
shipping_label_url: parsed[LABEL_KEY],
status: DISPATCHED_STATUS)
status: DISPATCHED_STATUS,
**confirmed_cost)
register_event
end
end

# El costo se escribe sólo si vino: sin él, el despacho no tiene por qué
# borrar uno que ya estuviera cargado.
def confirmed_cost
@shipping_cost.nil? ? {} : { shipping_cost: @shipping_cost }
end

# Sin número de seguimiento el despacho no sirve para nada: no se puede
# seguir el paquete ni emparejar los eventos que el courier empuje después.
# La etiqueta, en cambio, puede faltar — no todos los proveedores devuelven
Expand Down
13 changes: 13 additions & 0 deletions app/poros/shipments/invalid_shipping_cost_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Shipments
# El costo confirmado es un número, pero no uno que el envío pueda guardar:
# negativo, fuera del rango de la columna, NaN o infinito. Se detecta antes de
# pedir la etiqueta, así que el courier no se llegó a llamar. Es un dato del
# request, y el controller lo mapea a 400.
class InvalidShippingCostError < StandardError
def initialize(reasons)
super("shipping_cost #{reasons.to_sentence}")
end
end
end
Loading
Loading