Inventario
La API de inventario permite gestionar el stock de productos en múltiples ubicaciones. Cannahub utiliza el sistema de inventario multi-ubicación de Medusa para trackear stock por club y calcular totales en gramos para productos de flor.
El inventario en Cannahub usa un multiplicador de peso para flores: una unidad de "Blue Dream 7g" representa 7 gramos de inventario total.
El modelo de Inventario
ProductLocationStock
Representa el stock de una variante en una ubicación específica.
- Name
productId- Type
- string
- Description
ID del producto padre.
- Name
variantId- Type
- string
- Description
ID de la variante específica.
- Name
inventoryItemId- Type
- string
- Description
ID del item de inventario en Medusa.
- Name
locationId- Type
- string
- Description
ID de la ubicación de stock.
- Name
locationName- Type
- string
- Description
Nombre legible de la ubicación.
- Name
quantity- Type
- number
- Description
Unidades físicas en stock.
- Name
reservedQuantity- Type
- number
- Description
Unidades reservadas por pedidos pendientes.
- Name
availableQuantity- Type
- number
- Description
Unidades disponibles para venta (quantity - reserved).
- Name
incomingQuantity- Type
- number
- Description
Unidades en tránsito/esperadas.
Interface ProductLocationStock
interface ProductLocationStock {
productId: string
variantId: string
inventoryItemId: string
locationId: string
locationName: string
quantity: number
reservedQuantity: number
availableQuantity: number
incomingQuantity: number
}
Ejemplo
{
"productId": "prod_01HQ8BD5G",
"variantId": "variant_01HQ_BD_7G",
"inventoryItemId": "iitem_01HQ_BD_7G",
"locationId": "sloc_high_up",
"locationName": "High Up - Dispensario",
"quantity": 50,
"reservedQuantity": 5,
"availableQuantity": 45,
"incomingQuantity": 0
}
InventoryLevel
Modelo raw de Medusa para niveles de inventario.
- Name
id- Type
- string
- Description
ID único del nivel de inventario.
- Name
inventoryItemId- Type
- string
- Description
ID del item de inventario asociado.
- Name
locationId- Type
- string
- Description
ID de la ubicación.
- Name
stockedQuantity- Type
- number
- Description
Cantidad total en stock.
- Name
reservedQuantity- Type
- number
- Description
Cantidad reservada.
- Name
incomingQuantity- Type
- number
- Description
Cantidad entrante.
Interface InventoryLevel
interface InventoryLevel {
id: string
inventoryItemId: string
locationId: string
stockedQuantity: number
reservedQuantity: number
incomingQuantity: number
availableQuantity: number
location?: StockLocation
metadata?: Record<string, unknown>
createdAt?: string
updatedAt?: string
}
Multiplicador de Inventario
Para productos de flor, el inventario se calcula multiplicando las unidades por el peso de la variante.
Lógica del Multiplicador
inventoryTotal = quantity × weight
Ejemplo
| Variante | Unidades | Peso | Total (gramos) |
|---|---|---|---|
| Blue Dream 1g | 100 | 1g | 100g |
| Blue Dream 3.5g | 50 | 3.5g | 175g |
| Blue Dream 7g | 20 | 7g | 140g |
| Total | 170 | - | 415g |
Cálculo de inventario
function calculateInventoryTotal(
variant: ProductVariant,
inventoryQuantity: number
): number {
// Obtener peso de metadata
const weight = variant.metadata?.weight
?? variant.metadata?.quantity
?? 1
// Multiplicar para obtener total en gramos
return inventoryQuantity * weight
}
// Uso
const variant = {
id: 'variant_bd_7g',
name: 'Blue Dream 7g',
metadata: { weight: 7 }
}
const total = calculateInventoryTotal(variant, 20)
// total = 140 (gramos)
Listar inventario
Este endpoint lista los items de inventario con sus niveles por ubicación.
Parámetros de Consulta
- Name
locationId- Type
- string
- Description
Filtrar por ubicación de stock.
- Name
search- Type
- string
- Description
Filtrar por SKU o titulo.
- Name
limit- Type
- number
- Description
Límite de resultados (default: 20).
- Name
offset- Type
- number
- Description
Offset para paginación.
Request
curl -G https://app.cannahub.tech/api/inventory \
-H "Authorization: Bearer {token}" \
-d locationId=sloc_high_up
Response
{
"items": [
{
"id": "iitem_01HQ_BD_7G",
"sku": "BD-7G-001",
"title": "Blue Dream 7g",
"stockedQuantity": 50,
"reservedQuantity": 5,
"availableQuantity": 45,
"locationLevels": [
{
"id": "ilevel_01HQ",
"locationId": "sloc_high_up",
"stockedQuantity": 50,
"reservedQuantity": 5,
"incomingQuantity": 0,
"availableQuantity": 45
}
]
}
],
"count": 45,
"offset": 0,
"limit": 20
}
Obtener inventario de variante
Obtiene el detalle de un item de inventario específico con todos sus niveles.
Request
curl https://yourapp.com/api/inventory/iitem_01HQ_BD_7G \
-H "Authorization: Bearer {admin_token}"
Response
{
"item": {
"id": "iitem_01HQ_BD_7G",
"sku": "BD-7G-001",
"title": "Blue Dream 7g",
"requiresShipping": true,
"stockedQuantity": 250,
"reservedQuantity": 5,
"availableQuantity": 245,
"locationLevels": [
{
"id": "ilevel_01HQ",
"locationId": "sloc_high_up",
"stockedQuantity": 50,
"reservedQuantity": 5,
"incomingQuantity": 0,
"availableQuantity": 45
},
{
"id": "ilevel_02HQ",
"locationId": "sloc_deposito",
"stockedQuantity": 200,
"reservedQuantity": 0,
"incomingQuantity": 50,
"availableQuantity": 200
}
]
}
}
Actualizar nivel de inventario
Actualiza el nivel de stock en una ubicación específica.
Body
- Name
stockedQuantity- Type
- number
- Description
Nueva cantidad en stock.
- Name
incomingQuantity- Type
- number
- Description
Cantidad entrante esperada.
Request
curl -X PATCH https://app.cannahub.tech/api/inventory/levels \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"inventoryItemId": "iitem_01HQ_BD_7G",
"locationId": "sloc_high_up",
"stockedQuantity": 60
}'
Response
{
"level": {
"id": "ilevel_01HQ",
"locationId": "sloc_high_up",
"stockedQuantity": 60,
"reservedQuantity": 5,
"incomingQuantity": 0,
"availableQuantity": 55
}
}
Ajuste de inventario (incremento/decremento)
Para ajustes de inventario por ventas, devoluciones, o correcciones.
Reservaciones
Las reservaciones son creadas automáticamente cuando se agrega un item al carrito y se liberan si el carrito expira o se cancela.
Flujo de reservación
// 1. Cliente agrega item al carrito
// → Se crea reservación automática
// reserved_quantity += ordered_quantity
// 2. Cliente completa la orden
// → Se confirma la reservación
// stocked_quantity -= ordered_quantity
// reserved_quantity -= ordered_quantity
// 3. Si el carrito expira/cancela
// → Se libera la reservación
// reserved_quantity -= ordered_quantity
Stock Locations (Filiales)
Las ubicaciones de stock (filiales) son fundamentales para el inventario multi-ubicacion. Para documentacion completa sobre gestion de filiales, consulta la API de Filiales.
La API de Filiales (/api/locations) proporciona endpoints CRUD completos para gestionar ubicaciones de stock.
BFF Route: Producto con Inventario
Endpoint BFF que retorna un producto con inventario agregado de todas las ubicaciones en una sola llamada.
Beneficio
Reduce el problema N+1 de consultar inventario por cada variante.
Request
curl https://app.cannahub.tech/api/products/prod_01HQ8BD5G/with-inventory \
-H "Authorization: Bearer {token}"
Response
{
"product": {
"id": "prod_01HQ8BD5G",
"name": "Blue Dream Indoor",
"variants": [
{
"id": "variant_bd_1g",
"name": "Blue Dream 1g",
"weight": 1,
"price": 350
},
{
"id": "variant_bd_7g",
"name": "Blue Dream 7g",
"weight": 7,
"price": 2100
}
]
},
"inventory": {
"variant_bd_1g": {
"quantity": 100,
"available": 95,
"reserved": 5,
"totalGrams": 100,
"byLocation": [
{
"locationId": "sloc_high_up",
"locationName": "High Up",
"quantity": 100,
"available": 95
}
]
},
"variant_bd_7g": {
"quantity": 50,
"available": 45,
"reserved": 5,
"totalGrams": 350,
"byLocation": [
{
"locationId": "sloc_high_up",
"locationName": "High Up",
"quantity": 50,
"available": 45
}
]
}
},
"totalInventoryGrams": 450
}
BFF Route: Actualización Batch
Permite actualizar inventario de múltiples variantes en una sola llamada. Esta ruta reduce N llamadas individuales a 1 sola llamada.
Problema que resuelve
Flujo actual (N calls):
Para 10 variantes:
1. POST /admin/inventory-items/:id1/location-levels/:loc
2. POST /admin/inventory-items/:id2/location-levels/:loc
3. POST /admin/inventory-items/:id3/location-levels/:loc
...10 llamadas secuenciales
Body
- Name
updates- Type
- array
- Description
Array de actualizaciones a aplicar.
- Name
variantId- Type
- string
- Description
ID de la variante a actualizar.
- Name
locationId- Type
- string
- Description
ID de la ubicación de stock.
- Name
quantity- Type
- number
- Description
Nueva cantidad en stock.
- Name
adjustment- Type
- number
- Description
Ajuste relativo (+/-) en lugar de cantidad absoluta.
- Name
reason- Type
- string
- Description
Razón del ajuste para auditoría.
- Name
validateStock- Type
- boolean
- Description
Si es
true, valida que haya stock suficiente para decrementos.
Response
- Name
success- Type
- boolean
- Description
Si todas las actualizaciones fueron exitosas.
- Name
updated- Type
- number
- Description
Número de items actualizados exitosamente.
- Name
failed- Type
- number
- Description
Número de items que fallaron.
- Name
results- Type
- array
- Description
Detalle de cada actualización.
Request
curl -X POST https://app.cannahub.tech/api/inventory/batch-update \
-H "Authorization: Bearer {admin_token}" \
-H "Content-Type: application/json" \
-d '{
"updates": [
{
"variantId": "variant_bd_1g",
"locationId": "sloc_high_up",
"quantity": 150
},
{
"variantId": "variant_bd_7g",
"locationId": "sloc_high_up",
"quantity": 75
}
]
}'
Response
{
"success": true,
"updated": 2,
"failed": 0,
"results": [
{
"variantId": "variant_bd_1g",
"locationId": "sloc_high_up",
"previousQuantity": 100,
"newQuantity": 150,
"totalGrams": 150,
"status": "success"
},
{
"variantId": "variant_bd_7g",
"locationId": "sloc_high_up",
"previousQuantity": 50,
"newQuantity": 75,
"totalGrams": 525,
"status": "success"
}
],
"summary": {
"totalUnitsUpdated": 75,
"totalGramsUpdated": 525
}
}
Response con error parcial
{
"success": false,
"updated": 1,
"failed": 1,
"results": [
{
"variantId": "variant_bd_1g",
"status": "success",
"previousQuantity": 100,
"newQuantity": 150
},
{
"variantId": "variant_invalid",
"status": "error",
"error": "Variant not found"
}
]
}
Comparación de rendimiento
| Métrica | Sin BFF (10 items) | Con BFF | Mejora |
|---|---|---|---|
| Llamadas API | 10 | 1 | -90% |
| Latencia típica | ~2000ms | ~300ms | -85% |
| Manejo de errores | Manual por item | Centralizado | Simplificado |
| Auditoría | N registros separados | 1 batch log | Trazabilidad |
BFF Route: Stock Location Setup
Esta ruta crea una ubicación de stock completa con todo su fulfillment chain en una sola llamada. Normalmente esto requiere 7+ llamadas secuenciales a Medusa.
Problema que resuelve
Flujo actual (7+ calls secuenciales):
1. POST /admin/stock-locations → Crear ubicación
2. POST /admin/fulfillment-sets → Crear fulfillment set
3. POST /admin/service-zones → Crear service zone
4. POST /admin/geo-zones → Crear geo zone
5. POST /admin/shipping-options → Crear opción de envío
6. POST /admin/shipping-options → Crear opción de pickup
7. POST /admin/stock-locations/:id/sales-channels
→ Asociar sales channel
Request Body
- Name
name- Type
- string
- Description
Nombre de la ubicación.
- Name
address- Type
- object
- Description
Dirección física de la ubicación.
- Name
address_1- Type
- string
- Description
Dirección principal.
- Name
city- Type
- string
- Description
Ciudad.
- Name
province- Type
- string
- Description
Provincia/Estado.
- Name
postal_code- Type
- string
- Description
Código postal.
- Name
country_code- Type
- string
- Description
Código de país (ej: "AR").
- Name
enableShipping- Type
- boolean
- Description
Habilitar envíos desde esta ubicación (default: false).
- Name
enablePickup- Type
- boolean
- Description
Habilitar retiro en esta ubicación (default: true).
- Name
salesChannelId- Type
- string
- Description
ID del canal de ventas a asociar.
- Name
metadata- Type
- object
- Description
Datos adicionales (club, tipo, horarios, etc).
Response
- Name
stockLocation- Type
- StockLocation
- Description
La ubicación creada.
- Name
fulfillmentSet- Type
- FulfillmentSet
- Description
El fulfillment set asociado.
- Name
serviceZone- Type
- ServiceZone
- Description
La service zone creada.
- Name
shippingOptions- Type
- array
- Description
Las opciones de envío configuradas.
Request
curl -X POST https://app.cannahub.tech/api/stock-locations/setup \
-H "Authorization: Bearer {admin_token}" \
-H "Content-Type: application/json" \
-d '{
"name": "High Up - Dispensario Palermo",
"address": {
"address_1": "Av. Santa Fe 3200",
"city": "Buenos Aires",
"province": "CABA",
"postal_code": "C1425",
"country_code": "AR"
},
"enableShipping": false,
"enablePickup": true,
"salesChannelId": "sc_01KBCZXJCPVWX8REW8880R45SW",
"metadata": {
"club": "high-up",
"type": "dispensary",
"schedule": {
"monday": "10:00-20:00",
"tuesday": "10:00-20:00",
"wednesday": "10:00-20:00",
"thursday": "10:00-20:00",
"friday": "10:00-20:00",
"saturday": "12:00-18:00",
"sunday": "closed"
}
}
}'
Response
{
"stockLocation": {
"id": "sloc_01KDPALERMO",
"name": "High Up - Dispensario Palermo",
"address": {
"address_1": "Av. Santa Fe 3200",
"city": "Buenos Aires",
"province": "CABA",
"postal_code": "C1425",
"country_code": "AR"
},
"metadata": {
"club": "high-up",
"type": "dispensary"
}
},
"fulfillmentSet": {
"id": "fset_01KDPALERMO",
"name": "High Up - Dispensario Palermo Fulfillment",
"type": "pickup"
},
"serviceZone": {
"id": "szone_01KDPALERMO",
"name": "High Up Palermo Zone",
"geo_zones": [
{
"id": "gzone_01KDPALERMO",
"type": "country",
"country_code": "AR"
}
]
},
"shippingOptions": [
{
"id": "so_01KDPALERMO_PICKUP",
"name": "Retiro en Dispensario Palermo",
"price_type": "flat",
"amount": 0,
"is_return": false,
"metadata": {
"type": "pickup",
"locationId": "sloc_01KDPALERMO"
}
}
],
"summary": {
"totalApiCalls": 7,
"savedCalls": 6,
"executionTimeMs": 450
}
}
Flujo interno del BFF
Comparación de rendimiento
| Métrica | Sin BFF | Con BFF | Mejora |
|---|---|---|---|
| Llamadas API | 7+ | 1 | -86% |
| Latencia típica | ~1400ms | ~450ms | -68% |
| Complejidad cliente | Alta (secuencial) | Baja (1 request) | Simplificado |
| Rollback en error | Manual | Automático | Transaccional |
Mapeo Medusa
La API BFF transforma las respuestas de Medusa de snake_case a camelCase:
| UI (camelCase) | Medusa (snake_case) |
|---|---|
inventoryItemId | inventory_item_id |
locationId | location_id |
stockedQuantity | stocked_quantity |
reservedQuantity | reserved_quantity |
incomingQuantity | incoming_quantity |
availableQuantity | available_quantity |
requiresShipping | requires_shipping |
originCountry | origin_country |
hsCode | hs_code |
midCode | mid_code |
createdAt | created_at |
updatedAt | updated_at |
locationLevels | location_levels |
Diagrama de Flujo de Inventario
Origen de Cosecha (Seed-to-Sale)
Al editar stock, los modales permiten seleccionar una cosecha como origen para trazabilidad. Esto habilita el flujo completo de seed-to-sale.
Funcionalidad
- Selector de Cosecha: Combobox que filtra cosechas por la genética del producto
- Solo STOCKED: Solo muestra cosechas con estado
STOCKED(listas para dispensar) - Peso Disponible: Muestra los gramos disponibles de cada cosecha
- Warning de Exceso: Alerta si el stock a agregar excede el peso disponible de la cosecha
Modales Actualizados
EditProductStockModal- Stock por ubicaciónEditStockDialog- Stock multi-ubicación con acordeónEditStockModal- Nivel de inventario individual
La persistencia del origen de cosecha está pendiente de implementación en backend.
Los modales incluyen // TODO comments para la integración futura.
HarvestsCombobox Props
type Props = {
value: string | undefined
setValue: (value: string | undefined) => void
strainId?: string // Filtra por genética
setHarvest?: (harvest: Harvest | undefined) => void
disabled?: boolean
placeholder?: string
}
StockUpdateWithSource Type
// src/api/custom/stock.ts
type StockUpdateWithSource = {
inventoryItemId: string
locationId: string
quantity: number
sourceHarvestId?: string // Trazabilidad seed-to-sale
}
Uso en Modal
// Estado para selección de cosecha
const [selectedHarvestId, setSelectedHarvestId] =
useState<string | undefined>()
const [selectedHarvest, setSelectedHarvest] =
useState<Harvest | undefined>()
// Warning cuando stock > disponible
const harvestWarning = useMemo(() => {
if (!selectedHarvest) return null
const available = selectedHarvest.trimmedWeight || 0
const stockChange = newQty - originalQty
if (stockChange > available) {
return { change: stockChange, available }
}
return null
}, [selectedHarvest, formValues])
Flujo de Trazabilidad
Traducciones Agregadas
| Clave | ES | EN | DE |
|---|---|---|---|
sourceHarvest | Cosecha origen | Source Harvest | Quell-Ernte |
selectHarvest | Seleccionar cosecha a descontar | Select harvest to discount from | Ernte zum Abzug auswählen |
availableFromHarvest | Disponible de cosecha | Available from harvest | Verfügbar von Ernte |
harvestExceededWarning | La cantidad supera el peso disponible | Stock quantity exceeds harvest available | Bestandsmenge übersteigt verfügbares Erntegewicht |