Skip to content

feat: [TESIS-46] add the concurrent shipping rating engine - #60

Open
TomasMartin2004 wants to merge 2 commits into
masterfrom
TESIS-46-shipping-rating-engine
Open

feat: [TESIS-46] add the concurrent shipping rating engine#60
TomasMartin2004 wants to merge 2 commits into
masterfrom
TESIS-46-shipping-rating-engine

Conversation

@TomasMartin2004

Copy link
Copy Markdown
Contributor

🔗 Ticket de Jira

TESIS-46 — desbloquea a TESIS-47


📝 Descripción

Cotiza un envío contra todos los operadores logísticos activos de la empresa en paralelo y devuelve una lista normalizada para que el usuario elija. El fallo de un courier no voltea la cotización: cada llamada se aísla y el operador que no contesta simplemente no aparece entre las opciones.

Es la diferencia entre "no pudimos cotizar" y "no pudimos cotizar con Andreani".

Dos definiciones que la card dejaba abiertas

El depósito de origen viaja en el request, no se deduce. shipments no guarda origen y los ítems de una orden pueden estar en varios depósitos, así que cualquier regla automática sería una invención. Además el diseño ya tiene al usuario eligiéndolo: TESIS-58, "Wizard Orden Manual (Paso 2): Origen y Destino".

La ruta es POST /api/v1/orders/:order_id/quotes. La card nombra un servicio, pero su criterio de finalización termina en "listo para que el usuario pueda seleccionar una opción" — hace falta endpoint. Cuelga de la orden porque ahí viven los datos de entrada: el destino sale del cliente y el peso de los ítems. orders se declara con only: []; el ABM es TESIS-42 y define ahí sus acciones.

Qué plantilla sabe cotizar lo declara la plantilla

Un courier tiene endpoints distintos para cotizar y para despachar, y por convención del proyecto eso son dos Service (igual que Mercado Libre y Mercado Libre - Stock).

En vez de sumar una columna, la que cotiza es la que mapea el costo en su response_mapper. Es el mismo principio data-driven del resto de las integraciones —el template declara qué sabe contestar— y evita una migración por cada capacidad nueva. Pedirle una tarifa a una plantilla de despacho sería llamar al endpoint equivocado del proveedor; hay un spec para eso.


🛠️ Cambios realizados

  • Shipping::QuoteShipping: busca las integraciones activas cotizables, arma el contexto una sola vez, dispara un hilo por operador y normaliza. Ordena por precio, así que la más barata queda primera.
  • Service#courier? y Service#quotes_shipping?.
  • Integrations::HttpAdapter: timeouts por parámetro. Estaban fijos en 10s — está bien para un sync en background, no para una cotización que corre dentro de un request con el usuario esperando. Cotizar usa 4s, dentro de los 3-5 que pide la card.
  • Api::V1::ShippingQuotesController + OrderPolicy, ruta anidada, y la plantilla de cotización de Andreani en los seeds.
  • ApplicationController — ver abajo, es lo único que toca código compartido.

⚠️ El cambio compartido, que conviene mirar

ApplicationController nombraba :index en el only:/except: de los callbacks de verificación de Pundit:

after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index

Desde Rails 7.1 eso levanta AbstractController::ActionNotFound cuando el controller no define esa acción — y index lo definen las subclases. Todos los controllers de hoy tienen uno, así que nadie lo había pisado: el primero que no lo tenga se cae con un 404 engañoso antes de ejecutar su acción. Este es ese controller, y me costó tres iteraciones encontrarlo justamente porque el síntoma es un 404 y no un error de callback.

Ahora la condición va por predicado:

after_action :verify_authorized, unless: :index_action?
after_action :verify_policy_scoped, if: :index_action?

Comportamiento idéntico, deja de ser una trampa para el próximo controller de acción única, y de paso ya no hace falta silenciar Rails/LexicallyScopedActionFilter.

Un detalle de concurrencia que no se ve

Los hilos van envueltos en Rails.application.executor.wrap. Un hilo que corre código de la aplicación tiene que declararlo: es lo que permite cargar constantes todavía no autocargadas desde fuera del hilo principal. Sin eso, el primer hilo que toca una constante nueva deadlockea contra el load interlock de Zeitwerk y el proceso queda colgado, no lento.

Y el payload y las integraciones se resuelven antes de abrir los hilos, a propósito: adentro no puede haber ActiveRecord. Tomarían otra conexión del pool —que en test no ve la transacción del ejemplo— y Current.company_id no cruza el límite del hilo, así que el default_scope de CompanyScoped quedaría sin tenant.


🧪 Cómo probar

  1. bin/rails db:seed → crea la plantilla Andreani - Cotización y su integración para Distribuidora Norte
  2. POST /api/v1/orders/:order_id/quotes con { "quote": { "origin_warehouse_id": N } }
  3. Con un courier que responde → 200 con [{ company_integration_id, provider_name, shipping_cost, estimated_days }]
  4. Con el courier caído → 200 con data: [], no un 500
  5. Un order_id o un origin_warehouse_id de otra empresa → 404

📸 Evidencia

N/A (backend).

  • bundle exec rspec586 examples, 0 failures (18 nuevos)
  • rubocop --force-exclusion → 147 archivos, sin ofensas
  • brakeman -qNo warnings found

El criterio de paralelismo, medido

No hay spec de paralelismo, y es deliberado: WebMock no es thread-safe y tres respuestas concurrentes con retardo cuelgan el proceso en vez de fallar — lo comprobé. Un spec que se cuelga es peor que no tenerlo.

Se midió contra un servidor HTTP real (TCPServer, datos creados y rolleados en una transacción):

couriers=3 retardo_c/u=0.4s
secuencial_seria=1.2s
medido=0.46s
cotizaciones=3
RESULTADO: PARALELO

El tiempo total es el del courier más lento, no la suma — que es exactamente el criterio de finalización de la card. El script queda en el comentario de abajo por si lo quieren correr.


⚠️ Impacto y consideraciones

  • Breaking changes: no. Suma un endpoint, un PORO y dos predicados; el cambio en ApplicationController preserva el comportamiento.
  • Llamadas salientes dentro de un request: el techo es 4s por operador y corren en paralelo, así que el peor caso de la cotización es ~4s más el overhead, no 4s × cantidad de couriers.
  • Sin migraciones y sin variables de entorno nuevas.
  • Fuera de alcance: persistir la cotización elegida y el despacho — eso es TESIS-47, que esta card desbloquea.

Quotes a shipment against every active courier of the company at once and
returns a normalized list to choose from. One courier failing does not sink
the quote: each call is isolated and the operator that does not answer simply
does not appear among the options.

Two things the card left open, decided here.

The origin warehouse travels in the request instead of being derived. Shipments
do not record an origin, and the items of an order can sit in several
warehouses, so any automatic rule would be invented. The design already has the
user picking it — TESIS-58, "Wizard Orden Manual (Paso 2): Origen y Destino".

The route is POST /api/v1/orders/:order_id/quotes. The card names a service but
its acceptance criteria end at "ready for the user to pick an option", so an
endpoint is needed. It hangs off the order because that is where the quote
inputs live: destination from the customer, weight from the items. `orders` is
declared with `only: []` — the CRUD is TESIS-42 and defines its own actions.

Which template can quote is declared by the template. A courier has separate
endpoints for quoting and for dispatching, and by this project's convention
that means two Services (as with 'Mercado Libre' and 'Mercado Libre - Stock').
Rather than a new column, the one that quotes is the one mapping the cost in
its response_mapper — the same data-driven principle as the rest of the
integrations, and no migration per new capability. Asking a dispatch template
for a rate would call the wrong endpoint of the provider; there is a spec for
that.

The adapter's timeouts become parameters. They were fixed at 10s, which is fine
for a background sync but not for a quote running inside a request while the
user waits. Quoting uses 4s, within the 3-5s the card asks for.

Three fixes that came out of building this.

Threads that run application code need Rails.application.executor.wrap. Without
it the first thread to touch a not-yet-autoloaded constant deadlocks against
Zeitwerk's load interlock — the process hangs rather than slows.

ApplicationController named :index in `only:`/`except:` for the Pundit
verification callbacks. Since Rails 7.1 that raises ActionNotFound when the
controller does not define the action, and index is defined by the subclasses —
so the first controller without one died with a misleading 404 before running
its action. This is that controller. The condition now goes through a
predicate: identical behaviour, no longer a trap for the next single-action
controller, and the Rails/LexicallyScopedActionFilter silencing is no longer
needed. It is a shared file, so it is worth a look in review.

An empty list is a 200, not an error. "No operator answered in time" and "the
quote failed" are different things and the frontend needs to tell them apart.

Parallelism is not covered by a spec, and that is deliberate: WebMock is not
thread safe, and three concurrent delayed responses hang the process instead of
failing. It was measured against a real HTTP server instead — three couriers
delayed 0.4s each, which sequentially would be 1.2s, resolved in 0.46s with all
three quotes returned. The script is in the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomasMartin2004
TomasMartin2004 requested a review from a team as a code owner August 28, 2026 20:12

@LoLoo03 LoLoo03 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resumen de la revisión — TESIS-46-shipping-rating-engine

El diseño central del PR está sólido: aislar cada courier con rescue dentro del hilo, resolver payload/integrations con ActiveRecord antes de abrir threads, envolver en Rails.application.executor.wrap, y el fix de ApplicationController (que sí es un bug real y reproducible en Rails 7.1+ con raise_on_missing_callback_actions = true, activo en config/environments/test.rb:52). No encontré bugs de concurrencia ni de multi-tenancy en el flujo feliz.

Los 5 hallazgos que sobrevivieron la verificación, de más a menos importante:

[Alta] Llamada HTTP externa síncrona dentro de un request de Puma (shipping_quotes_controller.rb:11) — contradice literalmente architecture.md §7.4 ("siempre ejecutado desde un job"). El diseño mitiga bastante el riesgo (timeout de 4s, aislamiento por hilo), pero bajo carga puede agotar el pool de threads de Puma. Vale documentar la excepción o discutirla, no necesariamente cambiarla.
[Media] Dominio equivocado: Shipping en vez de shipments (quote_shipping.rb:3) — feature-structure.md da como ejemplo textual app/poros/shipments/quote_shipment.rb para esta misma feature. Esta PR es la primera en tocar el dominio de envíos y arranca con el namespace incorrecto; TESIS-47 puede terminar fragmentando el dominio entre Shipping:: y Shipments::.
[Baja] quote: {} sin origin_warehouse_id da 404 en vez de 400 (shipping_quotes_controller.rb:36) — el spec sólo cubre params: {} (clave quote ausente), no el caso de clave presente pero vacía.
[Baja] OrderPolicy::Scope sin uso (order_policy.rb:14) — código anticipado para el ABM de TESIS-42, que CLAUDE.md pide evitar.
[Baja] Filtro de couriers en Ruby, no en SQL (quote_shipping.rb:48) — trae todas las integraciones activas (e-commerce incluido) antes de descartar por tipo.

No pude correr bundle exec rspec/rubocop/brakeman localmente (no hay Ruby nativo en esta máquina, ver docker-verification-setup.md en memoria) — las cifras del PR (586 examples, rubocop limpio, brakeman limpio) no están verificadas por mí.

Five findings from the review of PR #60. Four applied, one documented.

The domain namespace was wrong. feature-structure.md lists
app/poros/shipments/quote_shipment.rb — for this exact feature, by name — and
this PR is the first to touch shipments, so starting on Shipping:: would have
split the domain in two before TESIS-47 even lands. Renamed the PORO, its
spec, the controller and the route to the documented vocabulary.

A blank origin_warehouse_id returned 404. The reproduction in the review was
`quote: {}`, which actually returns 400 — params.expect does raise on a missing
key. The real trigger is a key present with an empty value: that reaches
Warehouse.find('') and comes out as "not found", telling the client the
warehouse does not exist when what is missing is the parameter. Now a blank
value raises ParameterMissing like an absent one, and there is a spec for it.

OrderPolicy::Scope was anticipated code: no action lists orders yet. Removed,
with a note that TESIS-42 defines its own when the listing arrives.

The courier filter moves the type check into SQL so the e-commerce
integrations, which will never quote, are not loaded to be discarded in Ruby.
The capability check stays in Ruby on purpose: it inspects the values of a
jsonb mapper, and expressing that in SQL needs a jsonb_each_text inside an
EXISTS — harder to read than what it saves over the handful of rows the type
filter already leaves.

The synchronous HTTP call is deliberate and now documented where the rule
lives, in architecture.md §7.4, rather than left to be discovered in the code.
The user is waiting for the rates to pick one, so returning them from a job
would mean polling or a realtime channel for a value consumed on the spot. The
section states what bounds the risk — a shorter timeout of its own, real
concurrency so the ceiling is the slowest courier and not the sum, per-courier
isolation — and, more useful, when the exception stops being justified and the
call has to go back to a job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomasMartin2004

Copy link
Copy Markdown
Contributor Author

Aplicados los cinco. Cuatro corregidos, uno documentado. Commit 67af9d4.

🟠 [Media] Namespace del dominio — corregido

Tenías razón y es más grave de lo que parece por ser el primer PR que toca el dominio. feature-structure.md:45 lista app/poros/shipments/quote_shipment.rb con esa descripción exacta, y arrancar en Shipping:: habría partido el dominio en dos antes de que TESIS-47 siquiera exista.

Renombrado en bloque, con git mv para conservar el historial:

Antes Ahora
app/poros/shipping/quote_shipping.rb app/poros/shipments/quote_shipment.rb
Shipping::QuoteShipping Shipments::QuoteShipment
ShippingQuotesController ShipmentQuotesController
spec/poros/shipping/… spec/poros/shipments/…

No queda ninguna referencia a Shipping en app/, spec/ ni config/.

🔵 [Baja] 400 vs 404 — corregido, pero la reproducción no era esa

El hallazgo es real; el caso que lo dispara es otro. Lo medí antes de tocar nada:

quote: {}                        -> 400   ← `params.expect` sí levanta ParameterMissing
sin clave quote                  -> 400
origin_warehouse_id: ''          -> 404   ← este es el que estaba mal

O sea que expect cubre la clave ausente. Lo que se escapaba es la clave presente con valor vacío: llegaba a Warehouse.find('') y salía como "ese depósito no existe", cuando lo que falta es el parámetro. Ahora un valor en blanco levanta ParameterMissing igual que uno ausente, y tiene spec propio:

quote: {}                        -> 400
origin_warehouse_id: ''          -> 400
origin_warehouse_id: <válido>    -> 200

🔵 [Baja] OrderPolicy::Scope sin uso — eliminado

Código anticipado, sin ninguna acción que liste órdenes. Lo saqué y dejé anotado que TESIS-42 define el suyo cuando llegue el listado, para que el próximo no lo agregue "por las dudas".

🔵 [Baja] Filtro en Ruby — movido a SQL, parcialmente

El tipo va a SQL: las integraciones de e-commerce ya no se traen para descartarlas.

CompanyIntegration.where(is_active: true)
                  .joins(:service)
                  .where(services: { type: Service::COURIER })
                  .includes(:service)
                  .select { |ci| ci.service.quotes_shipping? }

La capacidad concreta se queda en Ruby a propósito, y está explicado al lado: quotes_shipping? mira los valores del response_mapper (jsonb) buscando el costo, y expresar eso en SQL pide un jsonb_each_text dentro de un EXISTS — más caro de leer que lo que ahorra, sobre un conjunto que el filtro por tipo ya dejó en unas pocas filas.

🔴 [Alta] Llamada síncrona en el request — documentada, no cambiada

Coincido en que contradice §7.4 literalmente, y en que documentarlo es lo que corresponde. Agregué la excepción en la propia §7.4, no en el PR ni en un comentario del código: si la regla vive ahí, la excepción tiene que vivir ahí también o nadie la encuentra.

El motivo es de producto: el usuario está esperando las tarifas para elegir una. Devolverlas desde un job obliga a sondear o a abrir un canal de tiempo real para un dato que se consume en el acto y caduca enseguida.

Lo que acota el riesgo, ya en el diseño:

  • Timeout propio y más corto — 4 s de apertura y lectura contra los 10 s del uso en background. El techo de la request es ese.
  • Concurrencia real — el total es el del courier más lento, no la suma. Medido contra un servidor HTTP real: tres couriers de 0,4 s resuelven en 0,46 s.
  • Aislamiento por operador — el que falla sale de la lista, no voltea la cotización.

Y lo más útil, que también quedó escrito: cuándo deja de estar justificada. Si la cantidad de couriers por empresa deja de ser un puñado, o si aparece un segundo caso de llamada saliente sincrónica, el patrón correcto vuelve a ser encolar y notificar.

Verificación

Sobre lo que no pudiste correr por no tener Ruby nativo:

bundle exec rspec                 → 587 examples, 0 failures   (+1: el spec del valor vacío)
rubocop --force-exclusion         → 147 archivos, sin ofensas
brakeman -q                       → No warnings found

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants