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=20HTTP/1.1 200 OKX-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) + 1hasNext = currentPage < totalPageshasPrev = currentPage > 1
Filtros que afectan el total
| Endpoint | Qué cuenta X-Total-Count |
|---|---|
GET /api/productos/ | Todos los productos activos del tenant |
GET /api/productos/search?termino=cafe | Productos que matchean “cafe” en nombre, código o código de barras |
GET /api/productos/search/nombre?nombre=latte | Productos con “latte” en el nombre |
GET /api/productos/categoria/5 | Productos de la categoría 5 |
Cálculos Server-Side (IVA y Utilidad)
IMPORTANTE: Los campos
utilidad,valor_ivayprecio_ivasiempre se calculan en el servidor. El cliente solo envíaprecio,precioc,ivayclasificacion_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 Tributaria | iva | precio_iva | valor_iva |
|---|---|---|---|
EXENTO | 0 | precio | 0 |
EXCLUIDO | 0 | precio | 0 |
INC | 0 | precio | 0 |
IVA 19% | 19 | precio / 1.19 | precio - precio_iva |
IVA 5% | 5 | precio / 1.05 | precio - 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ámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 100, máx 1000) |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de productos activos del tenant (excluye soft-deleted). No considera skip/limit. |
Response:
[ { "id": 1, "codigo": "PROD001", "nombre": "Café Latte", ... }]cURL:
curl "http://localhost/api/productos/?skip=0&limit=20" -H "Authorization: Bearer <token>"Python:
import httpxresp = 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óntotal_pages = (total + 20 - 1) // 20 # ceil divisionJavaScript:
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ámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
search_term | string | ❌ | Filtrar por nombre o código |
proveedor_id | int | ❌ | Filtrar por proveedor |
categoria_id | int | ❌ | Filtrar por categoría |
bodega_id | int | ❌ | Filtrar por bodega |
Respuesta exitosa: 200 OK
Response:
{ "total": 128}cURL:
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:
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:
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 httpxresp = 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:
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ámetro | Tipo | Descripción |
|---|---|---|
codigo | string | Código del producto (ej. PROD001) |
Request:
{ "stock": 87.0}Respuesta exitosa: 200 OK — producto actualizado
Errores: 404 (producto no encontrado)
cURL:
curl -X PUT http://localhost/api/productos/PROD001/stock \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"stock": 87.0}'Python:
import httpxresp = 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:
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ámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
termino | string | ✅ | Término de búsqueda (código, nombre o código de barras) |
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 100, máx 1000) |
proveedor_id | int | ❌ | Filtrar por proveedor |
categoria_id | int | ❌ | Filtrar por categoría |
bodega_id | int | ❌ | Filtrar por bodega |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de productos que matchean el término y filtros (en nombre o código). No considera skip/limit. |
cURL:
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 httpxresp = 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ámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
codigo | string | ✅ | 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:
curl "http://localhost/api/productos/search/codigo?codigo=PROD001" -H "Authorization: Bearer <token>"Python:
import httpxresp = 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ámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
nombre | string | ✅ | Texto parcial del nombre a buscar |
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 100, máx 1000) |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de productos con el texto en el nombre. No considera skip/limit. |
cURL:
curl "http://localhost/api/productos/search/nombre?nombre=Café&skip=0&limit=20" -H "Authorization: Bearer <token>"Python:
import httpxresp = 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ámetro | Tipo | Descripción |
|---|---|---|
categoria_id | int | ID de la categoría |
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 100, máx 1000) |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de productos en esa categoría. No considera skip/limit. |
cURL:
curl "http://localhost/api/productos/categoria/1?skip=0&limit=20" -H "Authorization: Bearer <token>"Python:
import httpxresp = 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"))