Skip to content
Open
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
33 changes: 32 additions & 1 deletion app/controllers/api/v1/products_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class ProductsController < ApplicationController
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 = [params[:page].to_i, 1].max
Expand Down Expand Up @@ -38,6 +39,7 @@ def index
end

def show
expose_version(@product)
render json: ProductSerializer.render(@product)
end

Expand Down Expand Up @@ -65,9 +67,11 @@ def update
product = Products::UpdateProduct.new(
product: @product,
params: product_params,
stocks: stock_params
stocks: stock_params,
expected_version: expected_version
).call

expose_version(product)
render json: ProductSerializer.render(product), status: :ok
end

Expand All @@ -78,6 +82,33 @@ def destroy

private

# La version del agregado viaja como ETag (TESIS-101). El cliente la
# devuelve en `If-Match` al guardar y el servidor rechaza la escritura si
# ya no es la vigente.
def expose_version(product)
response.set_header('ETag', %("#{Catalog::ProductVersion.new(product: product).call}"))
end

# `If-Match` puede venir con comillas, con el prefijo debil `W/` o como
# `*`. `*` significa "cualquier version, siempre que exista": el producto
# ya se resolvio en set_product, asi que equivale a no poner precondicion.
def expected_version
raw = request.headers['If-Match'].to_s.strip
return nil if raw.blank? || raw == '*'

raw.delete_prefix('W/').delete_prefix('"').delete_suffix('"')
end

# 412 y no 409, apartandose de lo que pedia la card. Es el codigo que HTTP
# define para una precondicion que no se cumple, y de paso resuelve solo el
# requisito de distinguirlo: este endpoint ya devuelve 409 por SKU
# duplicado y por lock de stock ocupado, y un tercer 409 obligaria al front
# a leer el cuerpo para saber cual es. Con 412 alcanza el status.
def render_precondition_failed(exception)
render json: { error: exception.message, current_version: exception.current_version },
status: :precondition_failed
end

def set_product
# Eager load de stocks y sus warehouses para evitar N+1 en el detalle.
@product = Product.includes(stocks: :warehouse).find(params.expect(:id))
Expand Down
79 changes: 79 additions & 0 deletions app/controllers/api/v1/stock_transfers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

module Api
module V1
class StockTransfersController < ApplicationController
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?

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

def create
authorize StockTransfer

transfer = Catalog::DispatchTransfer.new(
company: current_company, product: product_for_create,
origin_warehouse: warehouse_for(:origin_warehouse_id),
destination_warehouse: warehouse_for(:destination_warehouse_id),
quantity: transfer_params[:quantity]
).call

render json: StockTransferSerializer.render(transfer), status: :created
end

def receive
settle(:received)
end

def cancel
settle(:cancelled)
end

private

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

render json: StockTransferSerializer.render(transfer), status: :ok
end

def set_transfer
@transfer = StockTransfer.find(params.expect(:id))
authorize @transfer, :"#{action_name}?"
end

# find y no find_by en el scope del tenant: el default_scope de
# CompanyScoped ya acota, así que un id de otra empresa levanta
# RecordNotFound -> 404, que es lo que corresponde (no revelar que existe).
def product_for_create
Product.find(transfer_params[:product_id])
end

def warehouse_for(key)
Warehouse.find(transfer_params[key])
end

def transfer_params
# permit y no expect, igual que en productos: un body con company_id se
# ignora en lugar de devolver 400.
# rubocop:disable Rails/StrongParametersExpect
params.require(:stock_transfer)
.permit(:product_id, :origin_warehouse_id, :destination_warehouse_id, :quantity)
# rubocop:enable Rails/StrongParametersExpect
end

def render_conflict(exception)
render json: { error: exception.message }, status: :conflict
end
end
end
end
29 changes: 28 additions & 1 deletion app/models/product.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,23 @@ class Product < ApplicationRecord
# Sumar una categoría tiene que ser una línea acá, no una migración.
CATEGORIES = %w[Electronics Machinery Cabling Power].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
# `stocks` y agrupa por products.id. Sumar un join a `stock_transfers` daría
# producto cartesiano entre las dos tablas hijas y el SUM de stocks quedaría
# multiplicado por la cantidad de transferencias. Es una query igual —no N+1—
# pero sin contaminar la agregación existente.
IN_TRANSIT_SUBQUERY = <<~SQL.squish
SELECT COALESCE(SUM(st.quantity), 0) FROM stock_transfers st
WHERE st.product_id = products.id AND st.status = 'in_transit'
SQL

belongs_to :company
has_many :stocks, dependent: :destroy
# restrict_with_error: una transferencia en vuelo son unidades reales ya
# descontadas del origen. Borrar el producto las haría desaparecer sin rastro.
has_many :stock_transfers, dependent: :restrict_with_error
has_many :product_mappings, dependent: :destroy
# Bloquea el borrado si hay ítems de órdenes: son registros financieros y no
# deben evaporarse por un DELETE. destroy! levanta RecordNotDestroyed -> 409 (API).
Expand All @@ -28,7 +43,8 @@ class Product < ApplicationRecord
scope :with_total_stock, lambda {
left_joins(:stocks)
.group(:id)
.select('products.*', 'COALESCE(SUM(stocks.quantity), 0) AS total_stock')
.select('products.*', 'COALESCE(SUM(stocks.quantity), 0) AS total_stock',
"(#{IN_TRANSIT_SUBQUERY}) AS in_transit_quantity")
}

# Retorna el stock total consolidado. Si la fila fue cargada con el scope
Expand All @@ -40,6 +56,17 @@ def total_stock
has_attribute?(:total_stock) ? self[:total_stock].to_i : stocks.sum(:quantity)
end

# Unidades que salieron de un depósito y todavía no llegaron a otro. No están
# en `total_stock` a propósito: no son stock disponible en ningún nodo.
#
# Misma mecánica que total_stock: si la fila vino del scope, el alias del
# SELECT ya trae el agregado; si no, se suma por asociación (detalle, alta).
def in_transit_quantity
return self[:in_transit_quantity].to_i if has_attribute?(:in_transit_quantity)

stock_transfers.in_flight.sum(:quantity)
end

# 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 Down
58 changes: 58 additions & 0 deletions app/models/stock_transfer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

# Unidades de un producto que salieron de un depósito y todavía no llegaron a
# otro. Existe para poder expresar algo que `stocks` no puede: mientras viajan,
# las unidades no están en ningún nodo.
#
# Por eso NO se cuentan en `Product#total_stock`. El catálogo las expone aparte
# (`in_transit_quantity`), que es lo que alimenta el tab "In Transit" y el
# "+N Incoming" de TESIS-62.
#
# No hay estado borrador a propósito: una transferencia creada y no despachada
# deja las unidades en el origen, así que no aporta a los números que este
# modelo existe para producir. Crear una transferencia ES despacharla.
class StockTransfer < ApplicationRecord
include CompanyScoped

STATUSES = { in_transit: 'in_transit', received: 'received', cancelled: 'cancelled' }.freeze

belongs_to :company
belongs_to :product
belongs_to :origin_warehouse, class_name: 'Warehouse'
belongs_to :destination_warehouse, class_name: 'Warehouse'

# Mismo criterio que WebhookLog y FailedEvent: el enum da predicados y scopes,
# y `validate: true` invalida un estado desconocido en vez de explotar al
# asignarlo.
enum :status, STATUSES, validate: true

validates :quantity, numericality: { only_integer: true, greater_than: 0 }
validate :warehouses_are_distinct
validate :product_and_warehouses_belong_to_company

# Unidades en vuelo por producto. Se usa como subconsulta agregada desde el
# listado del catálogo: una sola query para toda la página, no una por fila.
scope :in_flight, -> { where(status: STATUSES[:in_transit]) }

private

def warehouses_are_distinct
return if origin_warehouse_id.blank? || destination_warehouse_id.blank?
return if origin_warehouse_id != destination_warehouse_id

errors.add(:destination_warehouse, 'must be different from the origin warehouse')
end

# El producto y los dos depósitos tienen que ser de la misma empresa que la
# transferencia: evita mover unidades entre tenants. Mismo criterio que Stock.
def product_and_warehouses_belong_to_company
return if company_id.blank?

{ product: product, origin_warehouse: origin_warehouse,
destination_warehouse: destination_warehouse }.each do |name, record|
next if record.blank? || record.company_id == company_id

errors.add(name, 'must belong to the same company as the transfer')
end
end
end
25 changes: 25 additions & 0 deletions app/policies/stock_transfer_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class StockTransferPolicy < ApplicationPolicy
def index?
user.present?
end

def create?
user.present?
end

def receive?
record.company_id == user.company_id
end

def cancel?
record.company_id == user.company_id
end

class Scope < ApplicationPolicy::Scope
def resolve
scope.where(company_id: user.company_id)
end
end
end
46 changes: 46 additions & 0 deletions app/poros/catalog/adjust_warehouse_stock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

module Catalog
# Suma o resta unidades de un producto en un depósito concreto.
#
# Es el único lugar que escribe `stocks.quantity` para las transferencias, y
# las tres transiciones (despachar, recibir, cancelar) pasan por acá — por eso
# existe como pieza aparte y no repetida en cada una.
#
# NO toma el advisory lock: lo toma quien la invoca, porque una transferencia
# necesita que la lectura del saldo y la escritura estén dentro del MISMO lock
# que la creación de la fila de transferencia. Tomarlo acá lo cerraría antes de
# tiempo y dejaría la ventana que el lock existe para cerrar (ADR-009).
class AdjustWarehouseStock < ApplicationPoro
def initialize(product:, warehouse:, delta:)
super()
@product = product
@warehouse = warehouse
@delta = delta.to_i
end

def call
stock = Stock.find_or_initialize_by(product_id: @product.id, warehouse_id: @warehouse.id)
resulting = stock.quantity.to_i + @delta
ensure_available!(stock, resulting)

stock.quantity = resulting
stock.save!
stock
end

private

# El CHECK `stocks_quantity_non_negative` es la última línea de defensa, pero
# llegaría como CheckViolation genérica. Cortar acá deja un error que nombra
# el depósito, lo disponible y lo pedido.
def ensure_available!(stock, resulting)
return unless resulting.negative?

raise InsufficientWarehouseStockError.new(
product_id: @product.id, warehouse_id: @warehouse.id,
available: stock.quantity.to_i, requested: @delta.abs
)
end
end
end
41 changes: 41 additions & 0 deletions app/poros/catalog/dispatch_transfer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Catalog
# Despacha unidades de un depósito a otro: descuenta del origen y deja la
# transferencia en vuelo.
#
# Crear la transferencia y mover el stock es una sola operación atómica, bajo
# el advisory lock del producto (ADR-009): la lectura del saldo del origen y su
# escritura tienen que estar dentro del mismo lock, o dos despachos
# simultáneos del mismo producto podrían descontar sobre el mismo saldo.
class DispatchTransfer < ApplicationPoro
def initialize(company:, product:, origin_warehouse:, destination_warehouse:, quantity:)
super()
@company = company
@product = product
@origin = origin_warehouse
@destination = destination_warehouse
@quantity = quantity
end

def call
WithStockLock.new(product_id: @product.id, wait: false).call do
transfer = build_transfer
# Se valida antes de tocar stock: un origen igual al destino o una
# cantidad inválida no deben dejar unidades descontadas.
transfer.save!
AdjustWarehouseStock.new(product: @product, warehouse: @origin,
delta: -transfer.quantity).call
transfer
end
end

private

def build_transfer
StockTransfer.new(company: @company, product: @product, origin_warehouse: @origin,
destination_warehouse: @destination, quantity: @quantity,
status: :in_transit, dispatched_at: Time.current)
end
end
end
24 changes: 24 additions & 0 deletions app/poros/catalog/insufficient_warehouse_stock_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Catalog
# No hay unidades suficientes en el depósito para el movimiento pedido.
# El controller lo mapea a 422: es un dato del request, no un fallo del sistema.
#
# Hermano de InsufficientStockError y no el mismo error: aquel responde "el
# producto no alcanza en ningún lado" (lo levanta DeductStock al ingerir una
# orden) y éste "no alcanza en ESTE depósito", que es la pregunta de una
# transferencia entre nodos. Comparten la causa pero no los datos: uno nombra
# el producto, el otro el depósito, lo disponible y lo pedido.
class InsufficientWarehouseStockError < StandardError
attr_reader :product_id, :warehouse_id, :available, :requested

def initialize(product_id:, warehouse_id:, available:, requested:)
@product_id = product_id
@warehouse_id = warehouse_id
@available = available
@requested = requested
super("warehouse #{warehouse_id} holds #{available} units of product " \
"#{product_id}, cannot move #{requested}")
end
end
end
Loading