Racoonity

La mercancía ya existe en algún lugar

Racoonity ya entiende ubicaciones, existencias, movimientos y transferencias. Cerramos I3 del Primer MVP y, con ella, una parte fundamental de la operación diaria.

Por El equipo de Racoonity •
10 min de lectura
  • #desarrollo
  • #arquitectura
  • #inventario
  • #openspec
  • #racoonity

Hasta ahora Racoonity ya sabía quién eras, en qué empresa estabas trabajando y qué productos existían. Pero todavía faltaba responder una pregunta bastante básica para cualquier operación real:

¿Dónde está la mercancía?

Ese fue el objetivo de I3, la tercera iteración vertical del Primer MVP.

Y después de varias semanas de contratos, migraciones, concurrencia, permisos, eventos, pruebas y una cantidad poco saludable de casos borde, podemos decirlo oficialmente:

La mercancía ya existe en algún lugar.

De “tenemos productos” a “tenemos inventario”

En la iteración anterior construimos el primer catálogo dinámico. Racoonity ya podía registrar categorías y productos, resolverlos mediante sus contratos públicos y exponerlos a través de su arquitectura de Actions y Queries.

Pero un producto por sí solo todavía es una descripción.

Para operar necesitamos saber cuánto existe, dónde está y cómo llegó ahí.

I3 agregó cuatro conceptos fundamentales al dominio de Inventory:

  • Location, para representar lugares operativos donde puede existir mercancía.
  • Balance, para representar la existencia actual de un producto en una ubicación.
  • Movement, como historial inmutable de cada cambio de stock.
  • Transfer, para coordinar movimientos entre dos ubicaciones.

Con esas cuatro piezas ya podemos contar una historia mucho más interesante que “este producto existe en el catálogo”.

Ahora podemos decir:

Había 20 unidades en Bodega. Movimos 8 al Piso. Quedan 12 en Bodega y 8 en Piso, y podemos explicar exactamente qué ocurrió para llegar a ese estado.

Las ubicaciones dejaron de ser un detalle

La primera parte de I3 fue construir las ubicaciones operativas.

Una sucursal puede tener, por ejemplo, una Bodega, un Piso de venta, una ubicación Interna o una ubicación para mercancía Dañada.

También existe el concepto de sales location: la ubicación que representa el stock disponible para la operación de venta.

Eso parece sencillo hasta que aparecen preguntas como estas:

  • ¿Qué pasa si dos procesos intentan cambiar la ubicación de venta al mismo tiempo?
  • ¿Puede archivarse una ubicación que todavía tiene mercancía?
  • ¿Qué ocurre si alguien intenta convertir la ubicación de venta en una ubicación interna?
  • ¿Puede haber dos ubicaciones de venta predeterminadas?

La respuesta de Racoonity es deliberadamente aburrida: no dejamos esas decisiones a la suerte.

Las operaciones sensibles toman locks en PostgreSQL, mantienen invariantes explícitas y fallan de forma cerrada cuando el estado no es válido.

La ubicación de venta no se reasigna mágicamente si alguien intenta romper su configuración. El sistema exige una operación explícita.

Balance no es historial

Una de las decisiones importantes de I3 fue separar dos conceptos que muchas implementaciones terminan mezclando:

Balance responde:

¿Cuánto tengo ahora?

Movement responde:

¿Por qué tengo esa cantidad?

El Balance es mutable porque representa el estado actual.

El Movement, en cambio, es append-only. Una vez confirmado, no existe una operación pública para editarlo o eliminarlo.

Si hubo un error, no reescribimos la historia: generamos un nuevo movimiento que corrige el estado.

Esta distinción nos permite mantener consultas rápidas sobre existencias sin sacrificar trazabilidad.

Entradas y salidas

Con los balances y movimientos listos llegaron las primeras operaciones reales de inventario:

  • entrada de mercancía;
  • salida de mercancía.

Una entrada incrementa el Balance y registra el Movement correspondiente.

Una salida hace lo contrario, con una condición bastante poco negociable:

el inventario nunca puede quedar negativo.

La regla existe en el dominio y además tiene defensa estructural en PostgreSQL.

Eso es intencional. Preferimos que una invariante importante tenga más de una línea de defensa antes que depender de que todos los futuros caminos de código recuerden comportarse bien.

También aparece aquí otra característica de Racoonity que ya veníamos construyendo desde I0: idempotencia.

Si una operación se reintenta con la misma llave y el mismo payload, Racoonity reproduce el resultado en lugar de duplicar la mutación.

Una entrada no puede convertirse accidentalmente en dos entradas porque una petición haya sido enviada de nuevo.

Ajustar no significa sumar o restar

El ajuste de inventario tiene una semántica ligeramente diferente.

Cuando alguien hace un conteo físico normalmente no quiere decir:

réstale tres.

Quiere decir:

conté 17 unidades; el sistema debe terminar en 17.

Por eso inventory.adjust trabaja con una cantidad objetivo y una versión esperada del Balance.

Si dos personas intentan corregir el mismo Balance basándose en la misma versión, solamente una puede ganar. La otra recibe un conflicto de versión en lugar de sobrescribir silenciosamente el trabajo anterior.

Y si el conteo físico coincide con el sistema, el ajuste es un verdadero no-op:

  • no cambia la versión;
  • no genera Movement;
  • no publica eventos.

Nada ocurrió, así que Racoonity tampoco pretende que ocurrió algo.

Transferir mercancía sin partir la realidad en dos

Después vino la operación más delicada de I3: las transferencias.

Mover 8 unidades de Bodega a Piso significa que cinco cosas deben permanecer coherentes dentro de una sola operación lógica:

  1. disminuir el Balance de Bodega;
  2. aumentar el Balance de Piso;
  3. persistir el Transfer;
  4. crear un Movement de salida;
  5. crear un Movement de entrada.

Todo eso ocurre dentro de una misma transacción.

No puede existir una realidad donde Bodega perdió mercancía pero Piso nunca la recibió.

Los dos movimientos comparten un transfer_id, por lo que siempre pueden reconstruirse como las dos piernas de la misma operación.

También tuvimos que resolver un problema clásico de concurrencia: una transferencia A → B ejecutándose al mismo tiempo que otra B → A.

Si cada operación bloqueara primero su origen, ambas podrían quedarse esperando para siempre.

Racoonity bloquea los balances en un orden determinista, independiente de cuál es origen y cuál destino. Ese pequeño detalle evita que dos transferencias inversas conviertan una tarde tranquila en una investigación sobre deadlocks.

Leer inventario tampoco debe cambiarlo

Después de construir las mutaciones llegó Racoon Librarian.

I3 agregó consultas para:

  • listar balances;
  • listar movimientos;
  • obtener un snapshot de stock;
  • consultar stock vendible;
  • recuperar una transferencia y sus movimientos asociados.

Y establecimos una regla especialmente importante:

leer inventario nunca debe crear inventario.

Si un Balance todavía no existe, algunas consultas pueden representarlo lógicamente como cero, pero no insertan una fila en PostgreSQL solo porque alguien abrió una pantalla.

Las Queries no generan movimientos, no escriben Outbox y no consumen llaves de idempotencia.

Son lectura de verdad.

¿Qué significa “stock vendible” por ahora?

En este Primer MVP decidimos mantenerlo deliberadamente simple.

El stock vendible es el stock de la ubicación de venta configurada para la sucursal.

Nada más.

Todavía no existen reservas de stock, picking, disponibilidad futura, compras pendientes, lotes, series o reglas avanzadas de almacén.

Y eso es importante: no queremos fingir un WMS que todavía no construimos.

La filosofía sigue siendo la misma:

simple por defecto, monstruoso por elección.

Primero construimos una base pequeña que podamos entender y demostrar. Después habrá tiempo de enseñarle más trucos al mapache.

Los eventos también tienen contrato

Las mutaciones relevantes de Inventory publican eventos mediante el Outbox.

Pero no decidimos “publicar algo” cada vez que cambia una fila.

La matriz es explícita.

Una entrada, por ejemplo, produce un evento de movimiento y uno de cambio de stock.

Un ajuste real agrega además su evento específico.

Una transferencia produce el evento de transferencia y los eventos correspondientes a sus dos movimientos y dos cambios de stock.

En cambio, operaciones como actualizar una Location o repetir un ajuste que no cambia la cantidad pueden producir exactamente cero eventos.

No queremos un Event Bus lleno de ruido accidental.

Permisos, aislamiento y no enumeración

Inventory también quedó integrado con el modelo de permisos que comenzamos a construir desde I1.

Racoon Bouncer determina qué capacidades puede ejecutar cada rol y Racoon Librarian/Dispatcher mantienen las fronteras entre lectura y mutación.

Además, company_id y branch_id nunca se toman como autoridad desde el cliente.

El contexto organizacional viene de la sesión autenticada.

De hecho, si alguien intenta inyectar esos campos en un payload donde no pertenecen, el contrato los rechaza.

También formalizamos reglas de no enumeración: un identificador perteneciente a otra empresa o sucursal no debe convertirse en una forma de descubrir que ese recurso existe.

La base de datos también tiene opinión

No quisimos que todas estas garantías existieran solamente porque nuestros handlers son buenos ciudadanos.

La migración de I3 incluye restricciones estructurales para proteger relaciones de empresa y sucursal, unicidad y estados imposibles.

Parte de la evidencia final consistió precisamente en saltarnos la aplicación e intentar insertar estados inválidos directamente con SQL.

PostgreSQL los rechazó.

Eso nos gusta.

Una buena regla de dominio no debería desaparecer simplemente porque algún código futuro encontró un camino nuevo hacia la base de datos.

Reiniciar Racoonity y descubrir que todo sigue ahí

Uno de los últimos checkpoints fue particularmente importante: levantar un entorno operativo, ejecutar mutaciones reales, apagar la composición y construirla otra vez contra la misma base de datos.

Después del reinicio:

  • la ubicación de venta seguía ahí;
  • los balances seguían ahí;
  • los movimientos seguían ahí;
  • las transferencias seguían ahí;
  • los permisos seguían ahí.

No existe una autoridad secreta en memoria sosteniendo el inventario con cinta adhesiva.

PostgreSQL sigue siendo la fuente durable del estado.

Doce oleadas para una frase bastante sencilla

La definición pública de esta iteración cabe casi en una oración:

Racoonity sabe cuánto stock existe, dónde está y cómo cambió.

Llegar ahí requirió bastante más trabajo de lo que esa frase deja ver.

implement-location-inventory terminó con:

  • 70 de 70 tareas completadas;
  • Checkpoints A–E cerrados;
  • 9 Actions;
  • 8 Queries;
  • 6 Events;
  • pruebas con PostgreSQL real;
  • journeys HTTP completos;
  • concurrencia repetida;
  • rollback ante fallos;
  • reconstrucción después de reinicio;
  • pruebas arquitectónicas;
  • evidencia versionada;
  • auditoría explícita de alcance.

Y quizá lo más satisfactorio del cierre fue lo que no tuvimos que hacer.

Las últimas oleadas fueron casi exclusivamente pruebas y evidencia. El hardening no terminó revelando que hubiera que reconstruir el módulo; terminó demostrando que las decisiones tomadas durante las primeras oleadas seguían sosteniéndose cuando empezamos a golpearlas con casos concurrentes, fallos de infraestructura y accesos inválidos.

Lo que I3 deliberadamente no es

Cerrar Inventory no significa que Racoonity se haya convertido silenciosamente en un WMS.

I3 no introduce:

  • lotes;
  • números de serie;
  • caducidades;
  • reservas de stock;
  • picking;
  • reposición;
  • costos o valuación;
  • FIFO, LIFO o FEFO;
  • compras o proveedores;
  • transferencias entre sucursales;
  • interfaces avanzadas de almacén.

Esas cosas pueden existir algún día si la evolución del producto las necesita.

Hoy no.

Hoy tenemos algo mucho más pequeño y, para nosotros, mucho más importante:

una semántica de inventario que podemos explicar, probar y confiar.

La madriguera sigue creciendo

Con I3 cerrada, Racoonity ya tiene contexto operativo, catálogo e inventario real.

La siguiente etapa del Primer MVP podrá empezar a apoyarse en algo que antes simplemente no existía: mercancía ubicada, movimientos auditables y stock consultable de forma consistente.

Todavía faltan varias piezas antes de que podamos sentarnos frente a Racoonity y hacer una venta completa.

Pero el mapache ya sabe dónde guardó las cosas.

Y después de todo lo que costó enseñárselo, más le vale no olvidarlo. 🦝