Skip to content

feat: [TESIS-103] add stock transfers between warehouses - #59

Open
TomasMartin2004 wants to merge 1 commit into
TESIS-102-product-category-and-primary-warehousefrom
TESIS-103-stock-transfers
Open

feat: [TESIS-103] add stock transfers between warehouses#59
TomasMartin2004 wants to merge 1 commit into
TESIS-102-product-category-and-primary-warehousefrom
TESIS-103-stock-transfers

Conversation

@TomasMartin2004

Copy link
Copy Markdown
Contributor

🔗 Ticket de Jira

TESIS-103 — bloquea a TESIS-62

⚠️ Base en TESIS-102-product-category-and-primary-warehouse (PR #58), no en master: las dos cards tocan ProductListSerializer. Cuando mergee el #58, GitHub reapunta este PR a master solo.


📝 Descripción

Construye el dominio que TESIS-62 estaba esperando. El tab "In Transit" y el "+N Incoming" del Master Catalog nunca fueron una columna faltante: no había forma de decir que unas unidades salieron de un depósito y todavía no llegaron a otro. stocks responde "cuántas unidades hay en este depósito" y no puede sostener unidades que no están en ninguno.

Un solo número derivado, no dos

A nivel producto, in transit e incoming son la misma cifra: unas unidades en vuelo están a la vez viajando y llegando a algún lado. Se separan sólo mirando por depósito, y la fila del catálogo es por producto. Así que la API expone in_transit_quantity una vez y alimenta las dos cosas: el filtro del tab y el badge.

Fuera de alcance

Órdenes de compra a proveedor. TESIS-62 dice "productos en movimiento entre nodos" — movimiento interno, no abastecimiento externo. Es otro dominio y otra card.

Sin estado borrador, a propósito

Una transferencia creada y no despachada deja las unidades en el origen, así que no aporta nada a los dos números que este modelo existe para producir, y sumaría un estado que nadie consulta. Crear una transferencia es despacharla: las unidades salen del origen en ese momento, entran al destino al recibirla y vuelven al origen al cancelarla.

Las unidades en vuelo no cuentan en total_stock: no están disponibles en ningún nodo, que es exactamente el motivo de exponerlas aparte.


🧠 Las tres decisiones que no se ven en el diff

1. Subconsulta escalar y no un segundo left_joins. with_total_stock ya hace join con stocks y agrupa por products.id. Un segundo join a otra tabla hija daría producto cartesiano entre las dos y multiplicaría el SUM de stock. Hay un spec que despacha dos transferencias del mismo producto y verifica que total_stock sigue bien, y otro que fija que la subconsulta agrega 0 queries por fila.

2. AdjustWarehouseStock no toma el advisory lock. Lo toma quien la invoca, y no es un olvido: la lectura del saldo tiene que estar dentro del mismo lock que la escritura de la fila de transferencia. Tomarlo adentro lo cerraría antes de tiempo y dejaría abierta justo la ventana que el lock existe para cerrar (ADR-009).

3. El chequeo de "sigue en vuelo" va adentro del lock. Si estuviera afuera, dos requests simultáneos sobre la misma transferencia lo pasarían los dos y las unidades entrarían al destino dos veces. Hay un spec que intenta liquidarla dos veces y verifica que el destino queda en 4, no en 8.


🛠️ Cambios realizados

  • Migración create_stock_transfers: company_id, product_id, origin_warehouse_id, destination_warehouse_id, quantity, status, dispatched_at (NOT NULL) y settled_at. Índice (company_id, status, product_id), que es exactamente como consulta el listado.
  • Tres CHECK constraints: quantity > 0, vocabulario de status, y origen ≠ destino. Con specs que prueban que update_all no los puede saltear — mismo criterio que stocks.quantity.
  • FKs en RESTRICT, no cascade: una transferencia registra unidades reales ya descontadas de un origen; borrar el producto o un depósito las dejaría huérfanas sin rastro. Mismo razonamiento que order_items → products.
  • StockTransfer (CompanyScoped) con enum validate: true, validación cross-company del producto y los dos depósitos, y scope in_flight.
  • POROs Catalog::DispatchTransfer y Catalog::SettleTransfer (recibir y cancelar comparten todo salvo a qué depósito vuelven las unidades), más Catalog::AdjustWarehouseStock e InsufficientStockError.
  • Product: in_transit_quantity con la misma mecánica que total_stock — alias del SELECT si la fila vino del scope, suma por asociación si no.
  • Endpoints: GET/POST /api/v1/stock-transfers, POST .../:id/receive, POST .../:id/cancel, con policy y filtros por status y product_id.
  • docs/guidelines/architecture.md: nota en §6 explicando por qué las unidades en vuelo viven aparte de stocks.

🧪 Cómo probar

  1. bin/rails db:migrate
  2. Crear stock en dos depósitos y despachar:
    POST /api/v1/stock-transfers con { "stock_transfer": { "product_id": …, "origin_warehouse_id": …, "destination_warehouse_id": …, "quantity": 4 } }
  3. GET /api/v1/products → esa fila trae in_transit_quantity: 4, y total_stock bajó 4.
  4. POST /api/v1/stock-transfers/:id/receive → el destino sube 4, in_transit_quantity vuelve a 0 y total_stock se recupera.
  5. Repetir el receive409.
  6. Despachar más unidades de las que tiene el origen → 422, y no queda ninguna fila escrita.

📸 Evidencia visual

N/A (backend).

Validación reproducida local:

  • bundle exec rspec514 examples, 0 failures (39 nuevos)
  • bundle exec rubocop --force-exclusion → 137 archivos, sin ofensas
  • bin/brakeman -qNo warnings found
  • Sembrado y comprobado por runner: NOR-002 reporta in_transit_quantity: 5 con total_stock: 20

⚠️ Impacto y consideraciones

  • Breaking changes: no. Suma una tabla, endpoints nuevos y un campo al serializer de productos.
  • total_stock no cambia de significado, pero sí de valor para un producto con transferencias en vuelo: esas unidades dejan de contarse. Es el comportamiento buscado y está cubierto por specs.
  • Sync saliente: las tres transiciones escriben stocks.quantity, así que disparan el after_commit de Stock y el stock actualizado se propaga a los canales sin código extra.
  • bin/rails db:seed sigue abortando en una corrida limpia, pero la causa es previa a esta branch: el bloque de TESIS-40 usa ml_integration sin asignarla nunca. El PR feat: [TESIS-45] add shipments and shipment_events data model #56 lo arregla y todavía no mergeó, así que deliberadamente no dupliqué el fix acá para no generar conflicto.
  • Sin cambios de entorno.

Builds the domain TESIS-62 was waiting on. The "In Transit" tab and the
"+N Incoming" hint of the Master Catalog were never a missing column: there
was no way to say that units left one warehouse and have not reached another
yet. `stocks` answers "how many units are in this warehouse" and cannot hold
units that are in neither.

One derived number, not two. At product level in-transit and incoming are
the same figure — units in flight are simultaneously travelling and arriving
somewhere. They only diverge per warehouse, and the catalog row is per
product. So the API exposes `in_transit_quantity` once and it feeds both the
tab filter and the badge.

Supplier purchase orders stay out. TESIS-62 says "products moving between
nodes", which is internal movement, not external replenishment. That is a
different domain and a different card.

No draft state, deliberately. A transfer created but not dispatched leaves
the units in the origin, so it adds nothing to the two numbers this model
exists to produce, and would add a state nobody queries. Creating a transfer
is dispatching it: the units come off the origin at that moment, land in the
destination on receive, and go back to the origin on cancel.

Units in flight are excluded from total_stock on purpose — they are not
available in any node, which is the whole reason for exposing them apart.

The catalog aggregates them with a scalar subquery rather than a second
left_joins. with_total_stock already joins stocks and groups by products.id;
joining a second child table would produce a cartesian product between them
and multiply the stock SUM. There is a spec that dispatches twice from the
same product and asserts total_stock stays correct, plus one asserting the
subquery adds zero queries per row.

Every transition runs inside the product's advisory lock (ADR-009), and the
read of the balance sits inside the same lock as the write — which is why
AdjustWarehouseStock does not take the lock itself: taking it there would
close it before the transfer row is written and leave open exactly the window
the lock exists to close. Settling re-checks in-flight status inside the lock
too, so two concurrent receives cannot add the units to the destination
twice.

The DB carries the guarantees the model cannot: CHECK constraints for a
positive quantity, the status vocabulary, and distinct endpoints, with specs
that prove update_all cannot bypass them. Foreign keys are RESTRICT rather
than cascade — a transfer records real units already deducted from an origin,
so deleting the product or a warehouse would strand them with no trace. Same
reasoning as order_items to products.

Not verified: `bin/rails db:seed` still aborts on a clean run, but the cause
predates this branch — `ml_integration` is referenced in the TESIS-40 seed
block without ever being assigned. PR #56 fixes it and is not merged yet, so
the fix is deliberately not duplicated here. The seeded transfer itself was
verified through a runner: NOR-002 reports in_transit_quantity 5 with
total_stock 20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomasMartin2004
TomasMartin2004 requested a review from a team as a code owner August 25, 2026 22:19
@TomasMartin2004
TomasMartin2004 requested review from LauAubert and removed request for a team August 25, 2026 22:19
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.

1 participant