feat: [TESIS-103] add stock transfers between warehouses - #59
Open
TomasMartin2004 wants to merge 1 commit into
Open
feat: [TESIS-103] add stock transfers between warehouses#59TomasMartin2004 wants to merge 1 commit into
TomasMartin2004 wants to merge 1 commit into
Conversation
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
requested review from
LauAubert
and removed request for
a team
August 25, 2026 22:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔗 Ticket de Jira
TESIS-103 — bloquea a TESIS-62
📝 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.
stocksresponde "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_quantityuna 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_stockya hace join constocksy agrupa porproducts.id. Un segundo join a otra tabla hija daría producto cartesiano entre las dos y multiplicaría elSUMde stock. Hay un spec que despacha dos transferencias del mismo producto y verifica quetotal_stocksigue bien, y otro que fija que la subconsulta agrega 0 queries por fila.2.
AdjustWarehouseStockno 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
create_stock_transfers:company_id,product_id,origin_warehouse_id,destination_warehouse_id,quantity,status,dispatched_at(NOT NULL) ysettled_at. Índice(company_id, status, product_id), que es exactamente como consulta el listado.quantity > 0, vocabulario destatus, y origen ≠ destino. Con specs que prueban queupdate_allno los puede saltear — mismo criterio questocks.quantity.order_items → products.StockTransfer(CompanyScoped) con enumvalidate: true, validación cross-company del producto y los dos depósitos, y scopein_flight.Catalog::DispatchTransferyCatalog::SettleTransfer(recibir y cancelar comparten todo salvo a qué depósito vuelven las unidades), másCatalog::AdjustWarehouseStockeInsufficientStockError.Product:in_transit_quantitycon la misma mecánica quetotal_stock— alias del SELECT si la fila vino del scope, suma por asociación si no.GET/POST /api/v1/stock-transfers,POST .../:id/receive,POST .../:id/cancel, con policy y filtros porstatusyproduct_id.docs/guidelines/architecture.md: nota en §6 explicando por qué las unidades en vuelo viven aparte destocks.🧪 Cómo probar
bin/rails db:migratePOST /api/v1/stock-transferscon{ "stock_transfer": { "product_id": …, "origin_warehouse_id": …, "destination_warehouse_id": …, "quantity": 4 } }GET /api/v1/products→ esa fila traein_transit_quantity: 4, ytotal_stockbajó 4.POST /api/v1/stock-transfers/:id/receive→ el destino sube 4,in_transit_quantityvuelve a 0 ytotal_stockse recupera.receive→ 409.📸 Evidencia visual
N/A (backend).
Validación reproducida local:
bundle exec rspec→ 514 examples, 0 failures (39 nuevos)bundle exec rubocop --force-exclusion→ 137 archivos, sin ofensasbin/brakeman -q→ No warnings foundNOR-002reportain_transit_quantity: 5contotal_stock: 20total_stockno 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.stocks.quantity, así que disparan elafter_commitdeStocky el stock actualizado se propaga a los canales sin código extra.bin/rails db:seedsigue abortando en una corrida limpia, pero la causa es previa a esta branch: el bloque de TESIS-40 usaml_integrationsin 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.