Skip to content

Productos

Productos

Prefijo: /api/productos
Requiere Auth: Sí
Permiso requerido: productos, *

Módulo de productos del catálogo. Incluye búsqueda por código y nombre, y filtro por categoría.

Nota: Los productos utilizan soft delete — al eliminar un producto se marca como eliminado sin borrarse físicamente.

Nota de rendimiento: El listado y las búsquedas (/search, /search/nombre, /search/codigo, /categoria/:id) se sirven desde una caché en memoria (TTL 10s). Al crear/actualizar/eliminar un producto o al cambiar su stock (ventas, sync), la caché se invalida al instante. Ver Caché y Rendimiento.

Nota de bodegas: Cada producto puede (opcionalmente) asignarse a una bodega con bodega_id. La respuesta incluye el objeto anidado bodega. Ver Bodegas.

Paginación y Total Count

Todos los endpoints de listado y búsqueda que retornan arrays incluyen el header HTTP X-Total-Count con el número total de registros que cumplen los filtros sin paginación. Esto permite al frontend implementar paginación completa sin llamadas adicionales.

Cómo funciona

GET /api/productos/?skip=0&limit=20
HTTP/1.1 200 OK
X-Total-Count: 1247 ← Total de productos activos (sin paginar)
Content-Type: application/json
[{"id":1,"nombre":"Café",...}, ... 20 items ...]

El frontend calcula:

  • totalPages = Math.ceil(X-Total-Count / limit)
  • currentPage = (skip / limit) + 1
  • hasNext = currentPage < totalPages
  • hasPrev = currentPage > 1

Filtros que afectan el total

EndpointQué cuenta X-Total-Count
GET /api/productos/Todos los productos activos del tenant
GET /api/productos/search?termino=cafeProductos que matchean “cafe” en nombre, código o código de barras
GET /api/productos/search/nombre?nombre=latteProductos con “latte” en el nombre
GET /api/productos/categoria/5Productos de la categoría 5

Cálculos Server-Side (IVA y Utilidad)

IMPORTANTE: Los campos utilidad, valor_iva y precio_iva siempre se calculan en el servidor. El cliente solo envía precio, precioc, iva y clasificacion_tributaria. Los valores enviados por el cliente para estos campos son ignorados.

Utilidad

utilidad = precio - precioc
  • Si utilidad < 0 → HTTP 400 con error descriptivo
  • El valor nunca se acepta del cliente, siempre se calcula

IVA según Clasificación Tributaria

Clasificación Tributariaivaprecio_ivavalor_iva
EXENTO0precio0
EXCLUIDO0precio0
INC0precio0
IVA 19%19precio / 1.19precio - precio_iva
IVA 5%5precio / 1.05precio - precio_iva

Fórmula general (para IVA > 0):

divisor = 1 + (iva / 100)
precio_iva = round(precio / divisor, 2)
valor_iva = round(precio - precio_iva, 2)

Ejemplo — Producto con IVA 19%, precio = $10.000:

{
"precio": 10000,
"precioc": 7000,
"iva": 19,
"clasificacion_tributaria": "IVA 19%"
}

Response calculado:

{
"utilidad": 3000,
"precio_iva": 8403.36,
"valor_iva": 1596.64
}

Ejemplo — Producto EXENTO, precio = $10.000:

{
"precio": 10000,
"precioc": 7000,
"iva": 0,
"clasificacion_tributaria": "EXENTO"
}

Response calculado:

{
"utilidad": 3000,
"precio_iva": 10000.0,
"valor_iva": 0.0
}

Clasificaciones Tributarias válidas: EXENTO, EXCLUIDO, INC, IVA 19%, IVA 5%


GET /api/productos/

Descripción: Lista todos los productos con paginación.
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
skipint❌Registros a saltar (default 0)
limitint❌Máximo de registros (default 100, máx 1000)

Respuesta exitosa: 200 OK

Headers de respuesta:

HeaderTipoDescripción
X-Total-CountintegerTotal de productos activos del tenant (excluye soft-deleted). No considera skip/limit.

Response:

[
{
"id": 1,
"codigo": "PROD001",
"nombre": "Café Latte",
...
}
]

cURL:

Terminal window
curl "http://localhost/api/productos/?skip=0&limit=20" -H "Authorization: Bearer <token>"

Python:

import httpx
resp = httpx.get("http://localhost/api/productos/",
params={"skip": 0, "limit": 20},
headers={"Authorization": f"Bearer :token"})
productos = resp.json()
total = int(resp.headers.get("X-Total-Count", "0")) # Leer total para paginación
total_pages = (total + 20 - 1) // 20 # ceil division

JavaScript:

const resp = await fetch('http://localhost/api/productos/?skip=0&limit=20', {
headers: {'Authorization': `Bearer $:token`}
});
const productos = await resp.json();
const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
const totalPages = Math.ceil(totalCount / 20);

GET /api/productos/count [NUEVO]

Descripción: Devuelve la cantidad total de productos activos del tenant (excluye soft-deleted), con filtros opcionales
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
search_termstring❌Filtrar por nombre o código
proveedor_idint❌Filtrar por proveedor
categoria_idint❌Filtrar por categoría
bodega_idint❌Filtrar por bodega

Respuesta exitosa: 200 OK

Response:

{
"total": 128
}

cURL:

Terminal window
curl "http://localhost/api/productos/count" -H "Authorization: Bearer <token>"
curl "http://localhost/api/productos/count?bodega_id=1" -H "Authorization: Bearer <token>"

GET /api/productos/:producto_id

Descripción: Obtiene un producto por ID
Requiere Auth: Sí
Permiso requerido: productos, leer

Respuesta exitosa: 200 OK
Errores: 404 (no encontrado)

cURL:

Terminal window
curl http://localhost/api/productos/1 -H "Authorization: Bearer <token>"

POST /api/productos/

Descripción: Crea un nuevo producto.
Unicidad de código: el codigo debe ser único entre los productos ACTIVOS del tenant — si ya existe, responde 409 Conflict. Aplica también al menú del mesero (POST /api/menu/productos).
Requiere Auth: Sí
Permiso requerido: productos, crear

Request: (campos utilidad, valor_iva, precio_iva son ignorados — se calculan en server)

{
"codigo": "PROD003",
"nombre": "Nuevo Producto",
"proveedor_id": 1,
"stock": 50,
"precio": 10000,
"precioc": 7000,
"iva": 19,
"clasificacion_tributaria": "IVA 19%",
"categoria_id": 1,
"unidad_medida": "94",
"codigo_barras": "7701234567891",
"bodega_id": 1
}

Respuesta exitosa: 201 Created
Errores: 400 (utilidad negativa / bodega_id no existe o no está activa en el tenant), 409 (codigo ya existe en un producto activo del tenant)

Response:

{
"id": 3,
"codigo": "PROD003",
"nombre": "Nuevo Producto",
"precio": 10000,
"precioc": 7000,
"utilidad": 3000,
"iva": 19.0,
"clasificacion_tributaria": "IVA 19%",
"precio_iva": 8403.36,
"valor_iva": 1596.64,
"stock": 50,
"tenant_id": "uuid-tenant",
"bodega_id": 1,
"bodega": { "id": 1, "nombre": "Bodega Central", "direccion": "Cra 1 # 2-3", "activa": true }
}

cURL:

Terminal window
curl -X POST http://localhost/api/productos/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"codigo":"PROD003","nombre":"Nuevo Producto","proveedor_id":1,"stock":50,"precio":10000,"precioc":7000,"iva":19,"clasificacion_tributaria":"IVA 19%","categoria_id":1,"unidad_medida":"94","unidad_id":414}'

Python:

import httpx
resp = httpx.post("http://localhost/api/productos/",
json={"codigo": "PROD003", "nombre": "Nuevo Producto", "proveedor_id": 1, "stock": 50, "precio": 10000, "precioc": 7000, "iva": 19, "clasificacion_tributaria": "IVA 19%", "categoria_id": 1, "unidad_medida": "94", "unidad_id": 414},
headers={"Authorization": f"Bearer :token"})
producto = resp.json()

JavaScript:

const resp = await fetch('http://localhost/api/productos/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`},
body: JSON.stringify({codigo: 'PROD003', nombre: 'Nuevo Producto', proveedor_id: 1, stock: 50, precio: 10000, precioc: 7000, iva: 19, clasificacion_tributaria: 'IVA 19%', categoria_id: 1, unidad_medida: '94', unidad_id: 414})
});

PUT /api/productos/:producto_id

Descripción: Actualiza un producto. Los campos utilidad, valor_iva y precio_iva se recalculan automáticamente en el servidor.
Unicidad de código: si el body incluye codigo y pasa a ser uno ya usado por otro producto activo del tenant, responde 409 Conflict (reenviar el mismo código del propio producto no conflicta).
Requiere Auth: Sí
Permiso requerido: productos, actualizar

Request: (solo enviar campos a modificar — utilidad, valor_iva, precio_iva son ignorados)

{
"precio": 12000,
"precioc": 8000,
"clasificacion_tributaria": "IVA 19%",
"iva": 19,
"unidad_id": 449,
"bodega_id": 2
}

Response:

{
"id": 1,
"precio": 12000,
"precioc": 8000,
"utilidad": 4000,
"clasificacion_tributaria": "IVA 19%",
"iva": 19.0,
"precio_iva": 10084.03,
"valor_iva": 1115.97,
"bodega_id": 2,
"bodega": { "id": 2, "nombre": "Bodega Norte", "direccion": null, "activa": true }
}

Respuesta exitosa: 200 OK
Errores: 400 (utilidad negativa / bodega_id no existe o no está activa), 404 (no encontrado), 409 (codigo ya existe en otro producto activo del tenant)

Para quitar la bodega de un producto, enviar "bodega_id": null.

cURL:

Terminal window
curl -X PUT http://localhost/api/productos/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"precio":12000,"precioc":8000,"clasificacion_tributaria":"IVA 19%","iva":19,"unidad_id":449}'

PUT /api/productos/:codigo/stock [NUEVO]

Descripción: Actualiza el stock de un producto por su código (string, no ID numérico). Reemplaza el stock actual con el valor enviado.
Comportamiento ante códigos duplicados: si existiera más de un producto activo con el mismo codigo (datos históricos duplicados en BD), se actualiza de forma determinista el de menor id. Esto evita el error 500 MultipleResultsFound que se producía en producción.
Requiere Auth: Sí
Permiso requerido: productos, actualizar

Parámetros path:

ParámetroTipoDescripción
codigostringCódigo del producto (ej. PROD001)

Request:

{
"stock": 87.0
}

Respuesta exitosa: 200 OK — producto actualizado
Errores: 404 (producto no encontrado)

cURL:

Terminal window
curl -X PUT http://localhost/api/productos/PROD001/stock \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"stock": 87.0}'

Python:

import httpx
resp = httpx.put("http://localhost/api/productos/PROD001/stock",
json={"stock": 87.0},
headers={"Authorization": f"Bearer :token"})
producto = resp.json()

JavaScript:

const resp = await fetch('http://localhost/api/productos/PROD001/stock', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`},
body: JSON.stringify({stock: 87.0})
});

DELETE /api/productos/:producto_id

Descripción: Elimina (soft delete) un producto
Requiere Auth: Sí
Permiso requerido: productos, eliminar

Respuesta exitosa: 204 No Content
Errores: 404 (no encontrado)

cURL:

Terminal window
curl -X DELETE http://localhost/api/productos/1 -H "Authorization: Bearer <token>"

Búsqueda

GET /api/productos/search

Descripción: Busca productos por un término libre (código, nombre o código de barras) con paginación.
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
terminostring✅Término de búsqueda (código, nombre o código de barras)
skipint❌Registros a saltar (default 0)
limitint❌Máximo de registros (default 100, máx 1000)
proveedor_idint❌Filtrar por proveedor
categoria_idint❌Filtrar por categoría
bodega_idint❌Filtrar por bodega

Respuesta exitosa: 200 OK

Headers de respuesta:

HeaderTipoDescripción
X-Total-CountintegerTotal de productos que matchean el término y filtros (en nombre o código). No considera skip/limit.

cURL:

Terminal window
curl "http://localhost/api/productos/search?termino=Café&skip=0&limit=20" -H "Authorization: Bearer <token>"
curl "http://localhost/api/productos/search?termino=Zapato&bodega_id=1" -H "Authorization: Bearer <token>"

Python:

import httpx
resp = httpx.get("http://localhost/api/productos/search",
params={"termino": "Café", "skip": 0, "limit": 20},
headers={"Authorization": f"Bearer :token"})
productos = resp.json()
total = int(resp.headers.get("X-Total-Count", "0"))

GET /api/productos/search/codigo

Descripción: Busca un producto por código exacto. Ideal para escáner de barras en punto de venta: el código escaneado se consulta aquí y el producto se agrega al carrito.
Comportamiento ante códigos duplicados: si existiera más de un producto activo con el mismo codigo (datos históricos duplicados en BD), retorna de forma determinista el de menor id. Esto evita el error 500 MultipleResultsFound que se producía en producción.
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
codigostring✅Código exacto del producto

Respuesta exitosa: 200 OK

Response:

{
"id": 1,
"codigo": "PROD001",
"nombre": "Café Latte",
"stock": 50,
"precio": 10000,
"precioc": 7000,
"utilidad": 3000,
"categoria": { "id": 1, "nombre": "Bebidas" }
}

Errores: 404 (no encontrado)

cURL:

Terminal window
curl "http://localhost/api/productos/search/codigo?codigo=PROD001" -H "Authorization: Bearer <token>"

Python:

import httpx
resp = httpx.get("http://localhost/api/productos/search/codigo",
params={"codigo": "PROD001"},
headers={"Authorization": f"Bearer {token}"})
producto = resp.json()

JavaScript:

const resp = await fetch('http://localhost/api/productos/search/codigo?codigo=PROD001', {
headers: {'Authorization': `Bearer ${token}`}
});
const producto = await resp.json();

GET /api/productos/search/nombre

Descripción: Busca productos por nombre (búsqueda parcial, case-insensitive) con paginación.
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
nombrestring✅Texto parcial del nombre a buscar
skipint❌Registros a saltar (default 0)
limitint❌Máximo de registros (default 100, máx 1000)

Respuesta exitosa: 200 OK

Headers de respuesta:

HeaderTipoDescripción
X-Total-CountintegerTotal de productos con el texto en el nombre. No considera skip/limit.

cURL:

Terminal window
curl "http://localhost/api/productos/search/nombre?nombre=Café&skip=0&limit=20" -H "Authorization: Bearer <token>"

Python:

import httpx
resp = httpx.get("http://localhost/api/productos/search/nombre",
params={"nombre": "Café", "skip": 0, "limit": 20},
headers={"Authorization": f"Bearer :token"})
productos = resp.json()
total = int(resp.headers.get("X-Total-Count", "0"))

GET /api/productos/categoria/:categoria_id

Descripción: Obtiene productos filtrados por categoría con paginación.
Requiere Auth: Sí
Permiso requerido: productos, leer

Parámetros path:

ParámetroTipoDescripción
categoria_idintID de la categoría

Parámetros query:

ParámetroTipoObligatorioDescripción
skipint❌Registros a saltar (default 0)
limitint❌Máximo de registros (default 100, máx 1000)

Respuesta exitosa: 200 OK

Headers de respuesta:

HeaderTipoDescripción
X-Total-CountintegerTotal de productos en esa categoría. No considera skip/limit.

cURL:

Terminal window
curl "http://localhost/api/productos/categoria/1?skip=0&limit=20" -H "Authorization: Bearer <token>"

Python:

import httpx
resp = httpx.get("http://localhost/api/productos/categoria/1",
params={"skip": 0, "limit": 20},
headers={"Authorization": f"Bearer :token"})
productos = resp.json()
total = int(resp.headers.get("X-Total-Count", "0"))