Skip to content
Merged
21 changes: 21 additions & 0 deletions app/controllers/api/v1/activity_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Api
module V1
# Lo último que pasó en la empresa, para el panel de la campanita
# (TESIS-162). Colección: viaja en `data` sin `meta`, porque no pagina —es
# un desplegable con un techo, no un listado (ADR-015).
class ActivityController < ApplicationController
def index
authorize :activity, :index?
# El feed no sale de un scope: son tres consultas a tres modelos, y los
# tres ya filtran por tenant —`Order` y `FailedEvent` con CompanyScoped,
# los eventos de envío por el join con `Shipment`—. No hay un
# `policy_scope` que aplicar sin inventar un modelo que no existe.
skip_policy_scope

render json: { data: Activity::BuildFeed.new(limit: scalar_param(:limit)).call }
end
end
end
end
3 changes: 2 additions & 1 deletion app/controllers/api/v1/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class OrdersController < ApplicationController
# Los datos del cliente que acepta el body. `items` queda afuera: se arma
# aparte, línea por línea, en `items_params`.
ORDER_FIELDS = %i[customer_name customer_document customer_address
customer_zip_code customer_city customer_province].freeze
customer_zip_code customer_city customer_province
requires_shipping].freeze

# Campos sobre los que corre el buscador del listado (TESIS-52). Son las
# formas en que un operador nombra una venta: el id con el que la conoce el
Expand Down
40 changes: 36 additions & 4 deletions app/controllers/api/v1/products_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ class ProductsController < ApplicationController

# Qué acepta el body del producto. `stocks` no entra: se arma aparte en
# `stock_params`, línea por línea.
PRODUCT_FIELDS = %i[sku name description category weight dimensions].freeze
PRODUCT_FIELDS = %i[sku name description category packaging technical_standard
weight dimensions].freeze

# Las pestañas del catálogo: cómo se llama cada una en la respuesta y con
# qué estado se filtra. «Todos» no filtra, por eso su estado es nil. El
# orden es el que muestra la pantalla.
CATALOG_TABS = { all: nil, available: 'available', low: 'low',
out_of_stock: 'out_of_stock' }.freeze

before_action :set_product, only: %i[show update destroy]
rescue_from ActiveRecord::RecordNotUnique, with: :render_conflict
Expand Down Expand Up @@ -49,6 +56,17 @@ def categories
render json: { data: Product::CATEGORIES }
end

# Cuántos productos tiene cada pestaña del catálogo, respetando el mismo
# buscador y el mismo filtro de categoría que el listado: si no, el número
# de la pestaña y las filas que se ven dirían cosas distintas.
#
# Una consulta por estado y no cuatro requests: es lo que la pantalla
# hacía desde el cliente (TESIS-162), y cada una era un round trip.
def counts
skip_authorization
render json: { data: CATALOG_TABS.transform_values { |status| tab_count(status) } }
end

def create
authorize Product

Expand Down Expand Up @@ -83,12 +101,26 @@ def destroy
# Las tres pestañas del catálogo más el buscador y la categoría. El orden
# importa: `with_total_stock` arma el GROUP BY y `by_stock_status` cuelga
# su HAVING de esa agregación.
def filtered_products
#
# Una sola definición de la cadena, y el estado por parámetro: el listado
# pasa el del request y cada pestaña pasa el suyo. Con dos copias, el
# invariante que la card pide —que el número de la pestaña y las filas
# visibles digan lo mismo— dependía de mantenerlas iguales a mano.
#
# `in_transit:` sale del scope cuando sólo se va a contar: la subconsulta
# es por fila y el conteo no la lee.
def filtered_products(status = scalar_param(:status), in_transit: true)
policy_scope(Product)
.search_catalog(scalar_param(:search))
.by_category(scalar_param(:category))
.with_total_stock
.by_stock_status(scalar_param(:status))
.with_total_stock(in_transit: in_transit)
.by_stock_status(status)
end

# Cuántas filas matchean cada pestaña. `nil` es «Todos»: el scope sin
# filtro de estado.
def tab_count(status)
count_of(filtered_products(status, in_transit: false))
end

# Cuántas filas matchean, sobre el scope YA filtrado.
Expand Down
7 changes: 7 additions & 0 deletions app/controllers/api/v1/shipment_quotes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ module Api
module V1
# Cotización logística de una orden (TESIS-46).
class ShipmentQuotesController < ApplicationController
rescue_from Shipments::PickupOrderError, with: :render_unprocessable

def create
order = Order.find(params.expect(:order_id))
authorize order, :quote?
# Una venta de retiro no tiene a quién pedirle precio: no hay envío. Sin
# esto se cotizaba igual —y se podía despachar— una orden que después
# `CreateShipment` rechaza, o peor, una que ya tenía su envío abierto y
# pasó a retiro por PUT: una etiqueta pagada por una venta que no sale.
raise Shipments::PickupOrderError.new(order: order) unless order.requires_shipping?

quotes = Shipments::QuoteShipment.for_order(
order: order, origin_warehouse: origin_warehouse
Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v1/shipments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ShipmentsController < ApplicationController
before_action :set_shipment, only: %i[show]

rescue_from Shipments::UnshippableOrderError, with: :render_unprocessable
rescue_from Shipments::PickupOrderError, with: :render_unprocessable
rescue_from Shipments::DuplicateShipmentError, with: :render_conflict
rescue_from Shipments::AlreadyDispatchedError, with: :render_conflict
rescue_from Shipments::InvalidCourierIntegrationError, with: :render_unprocessable
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/warehouses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def set_warehouse
# silencio las claves que no permite. (El comentario anterior decía que
# respondía 400; en Rails 8.1 no es así, y el spec de company_id lo fija.)
def warehouse_params
params.expect(warehouse: %i[name zip_code address])
params.expect(warehouse: %i[name zip_code address capacity])
end

# Tres cosas bloquean el borrado y el mensaje tiene que decir cuál. Se
Expand Down
15 changes: 15 additions & 0 deletions app/models/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ class Order < ApplicationRecord

STATUSES = %w[pending paid cancelled].freeze

# El estado final de una venta que no va a salir. Con nombre porque son varios
# los lugares que lo preguntan —el alta del envío, el despacho, el cálculo de
# lo comprometido, la edición y los reportes— y un literal repetido en cada
# uno es un typo esperando.
CANCELLED = 'cancelled'

# Las 24 jurisdicciones de la Argentina, con el nombre con el que se muestran
# en el select de provincia del alta manual (TESIS-58). No es una lista
# citable como nombres oficiales: Tierra del Fuego, por ejemplo, se llama
Expand Down Expand Up @@ -44,6 +50,15 @@ class Order < ApplicationRecord
validates :total_amount, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
# allow_nil: las órdenes anteriores a TESIS-128 y las de webhook no la tienen.
validates :customer_province, inclusion: { in: PROVINCES }, allow_nil: true
# `inclusion` y no `presence`: la columna es NOT NULL y el campo es editable
# por la API (TESIS-162), así que un `null` llegaba a la base y reventaba con
# NotNullViolation -> 500. `presence` tampoco serviría: `false` es un valor
# válido y para `presence` es ausencia.
#
# Un texto cualquiera sigue entrando como `true`: el casteo a booleano lo
# hace ActiveRecord **antes** de validar, así que desde acá no se distingue
# de un `true` legítimo. Es el comportamiento de Rails en toda la API.
validates :requires_shipping, inclusion: { in: [true, false] }
validate :company_integration_belongs_to_company

def display_name
Expand Down
83 changes: 78 additions & 5 deletions app/models/product.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ class Product < ApplicationRecord
# Los tres estados de disponibilidad, en el vocabulario de la pantalla.
STOCK_STATUSES = %w[out_of_stock low available].freeze

# Un envío en este estado todavía no salió, así que sus unidades siguen
# físicamente en el depósito aunque ya estén vendidas. Una orden sin envío
# —todavía no se abrió, o es un retiro en el local— está en la misma
# situación, y por eso el filtro acepta también el NULL del LEFT JOIN.
UNDISPATCHED_SHIPMENT_STATUSES = [nil, 'pending'].freeze

# Unidades en vuelo hacia/desde depósitos, como subconsulta escalar.
#
# Subconsulta y no un segundo left_joins: `with_total_stock` ya hace join con
Expand Down Expand Up @@ -53,11 +59,14 @@ class Product < ApplicationRecord
# tienen ninguna y no hay con qué inferirla.
validates :category, inclusion: { in: CATEGORIES }, allow_nil: true

scope :with_total_stock, lambda {
left_joins(:stocks)
.group(:id)
.select('products.*', 'COALESCE(SUM(stocks.quantity), 0) AS total_stock',
"(#{IN_TRANSIT_SUBQUERY}) AS in_transit_quantity")
# `in_transit:` lo pide quien va a leerlo. Es una subconsulta correlacionada
# —una por fila— y los contadores de las pestañas no la miran: pedirla ahí
# era pagarla cuatro veces para descartarla.
scope :with_total_stock, lambda { |in_transit: true|
columnas = ['products.*', 'COALESCE(SUM(stocks.quantity), 0) AS total_stock']
columnas << "(#{IN_TRANSIT_SUBQUERY}) AS in_transit_quantity" if in_transit

left_joins(:stocks).group(:id).select(*columnas)
}

# Filtro por disponibilidad, para las pestañas del catálogo.
Expand Down Expand Up @@ -139,6 +148,26 @@ def in_transit_quantity
stock_transfers.in_flight.sum(:quantity)
end

# Unidades vendidas que todavía no salieron del depósito, por depósito.
#
# El stock se descuenta al **crear** la orden (`Catalog::DeductStock`) y el
# despacho no vuelve a tocar `stocks`, así que estas unidades ya no figuran en
# ninguna fila de stock pero siguen estando en el estante hasta que el courier
# se las lleva. Son las que el detalle muestra como «Comprometido».
#
# Las líneas sin depósito (anteriores a TESIS-126) quedan afuera: no se pueden
# atribuir a ninguno, y contarlas en el total pero en ningún depósito dejaría
# una pantalla cuyas filas no suman el encabezado.
def committed_by_warehouse
@committed_by_warehouse ||= committed_scope
.group(:warehouse_id, 'warehouses.name')
.order(:warehouse_id)
.sum(:quantity)
.map do |(warehouse_id, name), quantity|
{ warehouse_id: warehouse_id, name: name, quantity: quantity.to_i }
end
end

# Unidades en vuelo hacia cada depósito: lo que todavía no figura en ningún
# número del destino. El saliente no va: ya está descontado del on hand del
# origen al despachar, y mostrarlo en esa fila se leería como si siguiera ahí.
Expand All @@ -159,6 +188,20 @@ def in_transit_by_warehouse
end
end

# Lo comprometido de todo el producto. Suma el desglose en vez de volver a la
# base: es el mismo número por definición, y así no puede discrepar con las
# filas que muestra la pantalla.
def committed_quantity
committed_by_warehouse.sum { |row| row[:quantity] }
end

# Lo que hay físicamente: lo que queda libre más lo vendido sin despachar.
def on_hand_quantity = total_stock + committed_quantity

# Lo que se puede prometer es lo que queda libre: lo vendido ya se descontó de
# `stocks` al crear la orden, así que no hay que volver a restarlo.
def available_to_promise = total_stock

# Depósito donde está el grueso de las unidades. Lo consume la columna
# "Location Node" del listado, que muestra un nodo y no el desglose.
#
Expand All @@ -174,4 +217,34 @@ def primary_stock
stocks.reject { |stock| stock.quantity.zero? }
.min_by { |stock| [-stock.quantity, stock.warehouse_id] }
end

private

# Las líneas que cuentan como comprometidas. El LEFT JOIN con `shipments` es
# lo que deja entrar a las órdenes que todavía no tienen envío; un INNER las
# dejaría afuera, que es justo el caso más común apenas entra una venta.
#
# **Los retiros en el local no cuentan.** Lo comprometido es lo vendido que
# todavía está en el estante, y lo que lo saca de ahí es el despacho. Una
# venta de retiro no tiene envío —`CreateShipment` lo rechaza— y `Order`
# no tiene un estado «retirada», así que nada la cerraría nunca: quedaría
# comprometida para siempre y el «En depósito» crecería con cada retiro.
#
# La decisión es tratar el mostrador como lo que es: registrar la venta y
# entregarla son el mismo momento, el cliente está ahí. Las unidades salen
# del estante al crear la orden, que es exactamente cuando `DeductStock` las
# saca de `stocks`. Así los dos números dicen lo mismo y no hace falta un
# estado nuevo.
#
# Lo que sí queda abierto es la cancelación: sus unidades vuelven al estante
# pero nada las devuelve a `stocks`, así que no las cuenta ni este scope ni
# `total_stock`. Eso lo cierra TESIS-168, que es la card que repone el
# stock al cancelar.
def committed_scope
order_items.joins(:order, :warehouse)
.left_outer_joins(order: :shipment)
.where.not(orders: { status: Order::CANCELLED })
.where(orders: { requires_shipping: true })
.where(shipments: { status: UNDISPATCHED_SHIPMENT_STATUSES })
end
end
17 changes: 17 additions & 0 deletions app/models/warehouse.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,24 @@ class Warehouse < ApplicationRecord
foreign_key: :destination_warehouse_id,
dependent: :restrict_with_error

# Lo que entra en la columna `integer` de la base. Declarar más unidades de
# las que un entero de 4 bytes puede guardar no es una capacidad: es un error
# de carga, y se responde como tal.
MAX_CAPACITY = 2_147_483_647

validates :name, presence: true
# Capacidad declarada del depósito, en unidades. Opcional: un depósito sin
# capacidad cargada no es un error, es uno del que nadie declaró el techo
# todavía, y la pantalla no dibuja su barra de ocupación. Mayor que cero
# porque un depósito de capacidad cero no podría guardar nada.
#
# El techo no es cosmético: la columna es `integer` de 4 bytes, así que un
# número más grande pasaba la validación y reventaba al guardar con
# ActiveModel::RangeError -> 500. Mismo criterio que `Shipment::MAX_SHIPPING_COST`.
validates :capacity,
numericality: { only_integer: true, greater_than: 0,
less_than_or_equal_to: MAX_CAPACITY },
allow_nil: true
validates :zip_code, presence: true
validates :address, presence: true

Expand Down
10 changes: 10 additions & 0 deletions app/policies/activity_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

# El feed no tiene modelo propio: se arma con órdenes, eventos de envío y
# eventos fallidos, y los tres ya están scopeados por tenant. La policy existe
# igual para que el controller no tenga que saltear la verificación de Pundit
# —`verify_authorized` está activo en toda la API— y para que quede un lugar
# donde apretar el permiso si algún día el feed deja de ser para cualquiera.
class ActivityPolicy < ApplicationPolicy
def index? = user.present?
end
Loading
Loading