Skip to content

Productos

Productos

Prefijo: /api/productos
Requiere Auth:
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=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:
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
skipintRegistros a saltar (default 0)
limitintMá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)
Requiere Auth:
Permiso requerido: productos, leer

Respuesta exitosa: 200 OK

Response:

{
"total": 128
}

cURL:

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

GET /api/productos/:producto_id

Descripción: Obtiene un producto por ID
Requiere Auth:
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
Requiere Auth:
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:

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.
Requiere Auth:
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:

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.
Requiere Auth:
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:
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:
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
terminostringTérmino de búsqueda (código, nombre o código de barras)
skipintRegistros a saltar (default 0)
limitintMáximo de registros (default 100, máx 1000)

Respuesta exitosa: 200 OK

Headers de respuesta:

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

cURL:

Terminal window
curl "http://localhost/api/productos/search?termino=Café&skip=0&limit=20" -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
Requiere Auth:
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
codigostringCódigo exacto del producto

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

cURL:

Terminal window
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:
Permiso requerido: productos, leer

Parámetros query:

ParámetroTipoObligatorioDescripción
nombrestringTexto parcial del nombre a buscar
skipintRegistros a saltar (default 0)
limitintMá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:
Permiso requerido: productos, leer

Parámetros path:

ParámetroTipoDescripción
categoria_idintID de la categoría

Parámetros query:

ParámetroTipoObligatorioDescripción
skipintRegistros a saltar (default 0)
limitintMá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"))