Skip to content
14 changes: 4 additions & 10 deletions app/controllers/api/v1/failed_events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,14 @@
module Api
module V1
class FailedEventsController < ApplicationController
include Paginatable

before_action :set_failed_event, only: %i[requeue discard]

def index
page = [params[:page].to_i, 1].max
per_page = params.fetch(:per_page, 20).to_i.clamp(1, 100)

events = filtered_events.order(created_at: :desc)
.offset((page - 1) * per_page)
.limit(per_page)
events, meta = paginate(filtered_events.order(created_at: :desc))

render json: {
data: FailedEventSerializer.render_as_hash(events),
meta: { page: page, per_page: per_page, total: filtered_events.count }
}
render json: { data: FailedEventSerializer.render_as_hash(events), meta: meta }
end

# POST /api/v1/failed-events/:id/retry (`retry` es palabra reservada en Ruby)
Expand Down
22 changes: 17 additions & 5 deletions app/controllers/api/v1/integrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,34 @@
module Api
module V1
class IntegrationsController < ApplicationController
include Paginatable

# El listado no pasa por Pundit: lo usa el widget de nodos del panel
# aunque la empresa no tenga la feature `integrations`, y sólo muestra las
# plantillas globales con el estado de la propia empresa. El alta y la
# modificación sí: ver CompanyIntegrationPolicy.
skip_after_action :verify_policy_scoped

# Envuelto en `data` como el resto de las colecciones (ADR-015). Era el
# Envuelto en `data` como el resto de las colecciones (ADR-015): era el
# único listado que devolvía un array pelado, y un array en la raíz no
# deja lugar para agregarle `meta` el día que pagine sin romper a quien
# lo consume.
# deja lugar para agregarle `meta`.
#
# Y pagina como todo listado (TESIS-108), aunque hoy `services` tenga pocas
# filas: es una tabla global que sólo crece cuando el administrador carga
# una plantilla nueva. Dejarla afuera sería una excepción que habría que
# justificar, y la regla vale más que el ahorro.
#
# `WHOLE_LIST_PER_PAGE` porque el panel la lee entera para dibujar sus
# nodos, no de a páginas.
def index
integrations = current_company.company_integrations.index_by(&:service_id)
services, meta = paginate(Service.order(:id), per_page: WHOLE_LIST_PER_PAGE)

render json: {
data: IntegrationStatusSerializer.render_as_hash(
Service.order(:id), integrations_by_service_id: integrations
)
services, integrations_by_service_id: integrations
),
meta: meta
}
end

Expand Down
31 changes: 12 additions & 19 deletions app/controllers/api/v1/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Api
module V1
class OrdersController < ApplicationController
include OptimisticLocking
include Paginatable

rescue_from ActiveRecord::RecordNotSaved, with: :render_unprocessable
rescue_from Catalog::InsufficientStockError, with: :render_insufficient_stock
Expand Down Expand Up @@ -31,28 +32,20 @@ class OrdersController < ApplicationController
].freeze

def index
# `scalar_param` y no `params[...]` directo: una query con `?page[]=1`
# entrega un Array y `to_i` sale con NoMethodError → 500. Ver
# ApplicationController.
page = [scalar_param(:page).to_i, 1].max
per_page = (scalar_param(:per_page) || 20).to_i.clamp(1, 100)

# La precarga alimenta dos columnas del serializer: `item_count` sale de
# order_items y `courier` de la cadena envío → integración → servicio.
# Sin ella, cada fila de la página dispara sus propias consultas.
orders = filtered_orders.preload(:order_items, shipment: { company_integration: :service })
.order(created_at: :desc, id: :desc)
.offset((page - 1) * per_page)
.limit(per_page)

render json: {
data: OrderListSerializer.render_as_hash(orders),
# El total se cuenta sobre el scope YA FILTRADO, no sobre la tabla de
# la empresa: de este número salen los KPIs de TESIS-53, que los pide
# con `?status=pending&per_page=1` y lee sólo el meta. Si contara de
# más, los KPIs mentirían.
meta: { page: page, per_page: per_page, total: filtered_orders.count }
}
#
# El `total` del meta lo cuenta el concern sobre el scope YA FILTRADO,
# no sobre la tabla de la empresa: de ese número salen los KPIs de
# TESIS-53, que los piden con `?status=pending&per_page=1` y leen sólo
# el meta. Si contara de más, los KPIs mentirían.
orders, meta = paginate(
filtered_orders.preload(:order_items, shipment: { company_integration: :service })
.order(created_at: :desc, id: :desc)
)

render json: { data: OrderListSerializer.render_as_hash(orders), meta: meta }
end

# Vocabulario del select de provincia del alta manual (TESIS-58). Mismo
Expand Down
23 changes: 14 additions & 9 deletions app/controllers/api/v1/product_mappings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module Api
module V1
class ProductMappingsController < ApplicationController
include Paginatable

MISSING_INTEGRATION = 'company_integration_id is required'
ALREADY_LINKED = 'external product already linked to another product in this integration'

Expand All @@ -12,15 +14,18 @@ class ProductMappingsController < ApplicationController
rescue_from ActiveRecord::RecordNotUnique, with: :render_conflict

def index
mappings = policy_scope(ProductMapping)
.where(product_id: @product.id)
.includes(company_integration: :service)
.order(:created_at)

# Se envuelve en `data` para que el front trate una sola shape en todo
# el árbol de /products. No lleva `meta` como el index de productos:
# los mappings son tantos como canales de venta y no se paginan.
render json: { data: ProductMappingSerializer.render_as_hash(mappings) }
scope = policy_scope(ProductMapping)
.where(product_id: @product.id)
.includes(company_integration: :service)
.order(:created_at)

# Los mapeos de un producto son tantos como canales de venta tenga la
# empresa: se leen enteros, así que van con `WHOLE_LIST_PER_PAGE`. Lo
# que cambia respecto de antes es que ahora hay un techo, que es el
# punto de TESIS-108: ningún listado devuelve una cantidad ilimitada.
mappings, meta = paginate(scope, per_page: WHOLE_LIST_PER_PAGE)

render json: { data: ProductMappingSerializer.render_as_hash(mappings), meta: meta }
end

def create
Expand Down
23 changes: 9 additions & 14 deletions app/controllers/api/v1/products_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ module Api
module V1
class ProductsController < ApplicationController
include OptimisticLocking
include Paginatable

before_action :set_product, only: %i[show update destroy]
rescue_from ActiveRecord::RecordNotUnique, with: :render_conflict
rescue_from ActiveRecord::RecordNotSaved, with: :render_unprocessable
rescue_from Catalog::StaleProductError, with: :render_precondition_failed

def index
page = [scalar_param(:page).to_i, 1].max
per_page = (scalar_param(:per_page) || 20).to_i.clamp(1, 100)

# La precarga es load-bearing: ProductListSerializer lee el depósito
# principal de cada fila, y sin ella son dos queries por producto
# (stocks + warehouse) en vez de dos para toda la página.
Expand All @@ -24,17 +22,14 @@ def index
# como eager_load, sumaría las columnas de stocks y warehouses a ese
# SELECT y Postgres rechazaría la consulta por columnas fuera del
# GROUP BY. preload garantiza las consultas separadas.
products = filtered_products.preload(stocks: :warehouse)
.order(created_at: :desc)
.offset((page - 1) * per_page)
.limit(per_page)

total = count_of(filtered_products)

render json: {
data: ProductListSerializer.render_as_hash(products),
meta: { page: page, per_page: per_page, total: total }
}
# `total:` explícito: el scope viene agrupado por products.id, así que
# su `.count` devolvería un Hash y no un entero (ver `count_of`).
products, meta = paginate(
filtered_products.preload(stocks: :warehouse).order(created_at: :desc),
total: count_of(filtered_products)
)

render json: { data: ProductListSerializer.render_as_hash(products), meta: meta }
end

def show
Expand Down
25 changes: 11 additions & 14 deletions app/controllers/api/v1/shipments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,24 @@ class ShipmentsController < ApplicationController
# El parámetro que falta es un 400 de contrato, no un 422 de negocio.
rescue_from ActionController::ParameterMissing, with: :render_bad_request

include Paginatable

# Cuánto del cuerpo del courier se propaga en el mensaje de error.
COURIER_ERROR_LIMIT = 300

def index
page = [params[:page].to_i, 1].max
per_page = params.fetch(:per_page, 20).to_i.clamp(1, 100)

# La precarga es load-bearing: ShipmentListSerializer lee el nombre del
# courier a través de la plantilla del Service, y sin ella son dos
# queries por fila (company_integrations + services).
shipments = filtered_shipments.preload(company_integration: :service)
.order(created_at: :desc, id: :desc)
.offset((page - 1) * per_page)
.limit(per_page)

render json: {
data: ShipmentListSerializer.render_as_hash(shipments),
# El total se cuenta sobre el scope filtrado, no sobre el total de la
# empresa: de acá sale el KPI de envíos activos (TESIS-53).
meta: { page: page, per_page: per_page, total: filtered_shipments.count }
}
#
# El total lo cuenta el concern sobre el scope filtrado, no sobre el
# total de la empresa: de acá sale el KPI de envíos activos (TESIS-53).
shipments, meta = paginate(
filtered_shipments.preload(company_integration: :service)
.order(created_at: :desc, id: :desc)
)

render json: { data: ShipmentListSerializer.render_as_hash(shipments), meta: meta }
end

def show
Expand Down
21 changes: 15 additions & 6 deletions app/controllers/api/v1/stock_transfers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@
module Api
module V1
class StockTransfersController < ApplicationController
include Paginatable

before_action :set_transfer, only: %i[receive cancel]
rescue_from Catalog::InsufficientWarehouseStockError, with: :render_unprocessable
rescue_from Catalog::SettleTransfer::NotInFlightError, with: :render_conflict

def index
transfers = policy_scope(StockTransfer)
.includes(:product, :origin_warehouse, :destination_warehouse)
.order(dispatched_at: :desc)
transfers = transfers.where(status: params[:status]) if params[:status].present?
transfers = transfers.where(product_id: params[:product_id]) if params[:product_id].present?
transfers, meta = paginate(filtered_transfers)

render json: { data: StockTransferSerializer.render_as_hash(transfers) }
render json: { data: StockTransferSerializer.render_as_hash(transfers), meta: meta }
end

def create
Expand All @@ -40,6 +38,17 @@ def cancel

private

# Las unidades en vuelo, filtrables por estado y por producto. El listado
# del catálogo pide las de un producto para el «+N Incoming» de su ficha.
def filtered_transfers
transfers = policy_scope(StockTransfer)
.includes(:product, :origin_warehouse, :destination_warehouse)
.order(dispatched_at: :desc)
transfers = transfers.where(status: params[:status]) if params[:status].present?
transfers = transfers.where(product_id: params[:product_id]) if params[:product_id].present?
transfers
end

def settle(outcome)
transfer = Catalog::SettleTransfer.new(transfer: @transfer, outcome: outcome).call

Expand Down
18 changes: 15 additions & 3 deletions app/controllers/api/v1/warehouses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@
module Api
module V1
class WarehousesController < ApplicationController
include Paginatable

before_action :set_warehouse, only: %i[show update destroy]
rescue_from ActiveRecord::RecordNotDestroyed, with: :render_conflict

def index
# with_stored_units agrega la suma de stocks en la misma consulta: sin
# el scope, el serializer pediria las unidades deposito por deposito.
warehouses = policy_scope(Warehouse).with_stored_units.order(created_at: :desc)

render json: { data: WarehouseSerializer.render_as_hash(warehouses) }
#
# `WHOLE_LIST_PER_PAGE` y no el default: el front usa este listado para
# llenar selects —el picker de origen del alta manual, el de los modales
# de producto—, no una tabla con paginador. Con 20 le faltarían depósitos
# sin que nada se lo diga; el techo sigue existiendo y `meta.total` le
# avisa si alguna vez lo pasa.
#
# `total:` explícito: with_stored_units agrupa por warehouses.id.
scope = policy_scope(Warehouse).with_stored_units.order(created_at: :desc)
warehouses, meta = paginate(scope, per_page: WHOLE_LIST_PER_PAGE,
total: policy_scope(Warehouse).count)

render json: { data: WarehouseSerializer.render_as_hash(warehouses), meta: meta }
end

def show
Expand Down
71 changes: 71 additions & 0 deletions app/controllers/concerns/api/v1/paginatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Api
module V1
# Paginación de los listados de la API (TESIS-108).
#
# Existía copiada literal en cuatro controllers —el mismo `[page.to_i, 1].max`
# y el mismo `clamp(1, 100)`— y ausente en otros cuatro, que devolvían la
# tabla entera. Esto es la única definición de las dos cosas: cuántas filas
# se devuelven y cómo se arma el `meta` que las acompaña.
#
# Todo listado de registros pasa por acá. Los vocabularios fijos
# (`/orders/provinces`, `/products/categories`) y el resultado de una acción
# (`/orders/:id/quotes`) no: su largo lo decide el código, no la empresa.
#
# La forma de la respuesta la fija ADR-015: la colección va en `data` y el
# `meta` al lado, con `page`, `per_page` y `total`. El `total` cuenta el
# scope **ya filtrado**, no la tabla: de ahí salen los contadores de las
# pestañas y los KPIs del panel.
module Paginatable
extend ActiveSupport::Concern

# Techo duro. Nadie puede pedir más, venga el número de donde venga: es lo
# que impide que un listado devuelva una cantidad ilimitada de filas.
MAX_PER_PAGE = 100

# Cuántas filas devuelve un listado que nadie acotó. Es el tamaño de una
# pantalla paginada.
DEFAULT_PER_PAGE = 20

# Para los listados que el consumidor lee enteros —depósitos, mapeos,
# integraciones: los usa para llenar un select, no una tabla con
# paginador—. Siguen teniendo techo; lo que cambia es que el default no
# los recorta antes de tiempo.
#
# Si alguna empresa pasa de acá, el consumidor se entera por `meta.total`,
# que va a ser mayor que las filas recibidas, y ahí le toca paginar. Es
# preferible a que el default de 20 le esconda depósitos en silencio.
WHOLE_LIST_PER_PAGE = MAX_PER_PAGE

private

# Devuelve `[filas, meta]`.
#
# `total:` se puede pasar cuando contar el scope no es directo: el catálogo
# viene agrupado por `products.id`, así que su `.count` devuelve un Hash y
# el controller ya sabe cómo contarlo (ver `count_of`).
def paginate(scope, per_page: DEFAULT_PER_PAGE, total: nil)
page = page_number
size = page_size(per_page)

[scope.offset((page - 1) * size).limit(size),
{ page: page, per_page: size, total: total || scope.count }]
end

# Página pedida, nunca menor que 1. `page=0` y `page=-3` se acotan en vez
# de romper: un offset negativo es un error de SQL, y un 400 por un número
# que se puede interpretar sería antipático.
def page_number
[scalar_param(:page).to_i, 1].max
end

# `scalar_param` y no `params[...]`: `?per_page[]=1` entrega un Array y
# `Array#to_i` no existe (TESIS-124). Un valor no numérico cae en `to_i`
# a 0 y el `clamp` lo lleva al mínimo.
def page_size(default)
(scalar_param(:per_page) || default).to_i.clamp(1, MAX_PER_PAGE)
end
end
end
end
Loading
Loading