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.
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)
Requiere Auth: Sí
Permiso requerido: productos, leer
Respuesta exitosa: 200 OK
Response:
{ "total": 128}cURL:
curl "http://localhost/api/productos/count" -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
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"}Respuesta exitosa: 201 Created
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"}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.
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}Response:
{ "id": 1, "precio": 12000, "precioc": 8000, "utilidad": 4000, "clasificacion_tributaria": "IVA 19%", "iva": 19.0, "precio_iva": 10084.03, "valor_iva": 1115.97}Respuesta exitosa: 200 OK
Errores: 400 (utilidad negativa), 404 (no encontrado)
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.
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) |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de productos que matchean el término (en nombre, código o código de barras). No considera skip/limit. |
cURL:
curl "http://localhost/api/productos/search?termino=Café&skip=0&limit=20" -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
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
Errores: 404 (no encontrado)
cURL:
curl "http://localhost/api/productos/search/codigo?codigo=PROD001" -H "Authorization: Bearer <token>"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"))