Pega una lista con referencia y cantidad por linea.
Separados por tabulador, espacio o punto y coma. Puedes copiar directamente desde Excel.
| Proveedor | Cod. Proveedor | Ud/Caja | PVP | Dto.1 (%) | Dto.2 (%) | Dto.3 (%) | Precio Final | Cantidad | Accion |
|---|
Selecciona el proveedor para cada producto. Por defecto se muestra el proveedor con mejor precio.
| Referencia | Producto | Cantidad | Ud/Caja | Proveedor | PVP | Dto.1 | Dto.2 | Dto.3 | Precio Final | Importe |
|---|
Calcula qué productos conviene reponer, cuánto y con qué urgencia, a partir de las ventas, el stock de cada almacén, lo que ya está pedido y los plazos reales de cada proveedor.
| Prioridad | Producto | DemandaUnidades por día previstas (no la media histórica: usa el patrón de ventas y la estacionalidad si está activada). El gráfico muestra las ventas semanales de la ventana. | CoberturaDías que dura el stock libre (disponible − comprometido) al ritmo de demanda. La barra se pone roja si se acaba antes de que pueda llegar un pedido nuevo (plazo de entrega). | Posición de stockPronosticado de Odoo (disponible + entrante − comprometido) + cantidades ya incluidas en solicitudes de presupuesto en borrador. Es lo que se compara con el punto de pedido. | Pto. pedido / ObjetivoPunto de pedido: cuando la posición cae por debajo hay que pedir. Objetivo: nivel al que se repone. Ambos incluyen el stock de seguridad. | Cant. sugerida | Proveedor (mejor precio) |
|---|
| Referencia | Producto | Unidades en stock | Coste unitario | Valor inmovilizado |
|---|
SANE-COMPRAS es la herramienta de compras de SANE RIEGO, S.A. Conecta directamente con Odoo 18 para consultar productos, comparar proveedores, calcular qué hay que reponer y generar solicitudes de presupuesto en borrador.
Entra en compras.saneriego.com con tu usuario y contraseña de Odoo (las mismas con las que entras en Odoo). Solo pueden acceder usuarios cuyo usuario de Odoo sea un correo @saneriego.com. La sesión dura 12 horas; si el servidor se reinicia tendrás que volver a entrar. Tras 6 intentos fallidos con el mismo usuario en 10 minutos, el acceso a ese usuario se bloquea 10 minutos.
2M280EL), parte del nombre, el código de barras o el código del proveedor. El autocompletado te sugerirá productos a medida que escribas. La búsqueda por referencia no distingue mayúsculas de minúsculas y trata los guiones bajos literalmente.En la tabla de proveedores, el primero es siempre el de mejor precio final, marcado en verde. La columna Ud/Caja indica cuantas unidades vienen en cada caja del proveedor. Si el proveedor tiene unidades por caja configuradas, la cantidad se redondea automáticamente hacia arriba a cajas completas. Ajusta la cantidad y pulsa Seleccionar para anadirlo al carrito.
Si tienes una lista larga de referencias (por ejemplo, desde un Excel o un papel), puedes importarla de golpe:
El carrito agrupa automaticamente los productos por proveedor. Para cada proveedor se muestra:
Puedes modificar cantidades directamente en el carrito y eliminar productos con la X.
Junto al total hay un desplegable Recibir en: elige el almacén (SANE1 o SANE2) donde llegará la mercancía. Si vienes del análisis, se rellena con el almacén analizado. Con «Almacén por defecto de Odoo» no se indica ninguno y Odoo usa el suyo.
[codigo_proveedor] Nombre producto, igual que al crear pedidos manualmente en Odoo.SANE-COMPRAS como documento origen y, si lo elegiste, la recepción asignada al almacén de destino.La pestaña Análisis de reabastecimiento te dice qué productos hay que reponer, cuánto y con qué urgencia. No se limita a comparar el stock con un mínimo: tiene en cuenta cuánto se vende, cuánto tarda de verdad cada proveedor, lo que ya está pedido (incluidas las solicitudes en borrador) y la importancia de cada producto.
| Prioridad | Significado |
|---|---|
| ALTA | La posición de stock está por debajo del punto de pedido y el stock libre se agotará antes de que pueda llegar un pedido nuevo (cobertura menor que el plazo de entrega). Hay que pedir ya y aun así puede haber rotura. |
| MEDIA | La posición está por debajo del punto de pedido, pero el stock libre aguanta hasta que llegue un pedido. Conviene pedir ahora. |
| ANTICIPAR | Todavía no se ha llegado al punto de pedido, pero se alcanzará antes del próximo pedido (dentro de los «cada cuántos días pedís»). Útil para completar un pedido al mismo proveedor y llegar al mínimo de portes. |
| SIN NECESIDAD | Stock suficiente. Oculto por defecto; puedes mostrarlo con el filtro «Sin necesidad». |
| Columna | Qué significa |
|---|---|
| Clase ABC | A = los productos que suman el 80 % del valor vendido en la ventana, B = hasta el 95 %, C = el resto. Los A reciben más stock de seguridad. |
| Demanda | Unidades por día previstas. No es la media simple: se calcula según el patrón de ventas del producto (ver «Cómo se calcula»). El gráfico son las ventas por semana; la flecha, la tendencia (↑ las últimas 4 semanas venden más que la media, ↓ menos). |
| Cobertura | Días que dura el stock libre (disponible menos comprometido) al ritmo de demanda. La barra es roja si se acaba antes del plazo de entrega, naranja si aguanta el plazo pero no hasta el siguiente pedido, verde si cubre ambos. Debajo, el plazo de entrega (* = teórico, aún no hay recepciones previas de ese producto). |
| Posición de stock | Stock pronosticado de Odoo (disponible + entrante − comprometido) más las cantidades de solicitudes de presupuesto en borrador. Debajo se desglosa: disponible, entrante («entr.»), en borrador («borr.») y comprometido («comp.»). Al pasar el ratón se ve el stock por almacén. |
| Pto. pedido / Objetivo | Punto de pedido: si la posición cae por debajo, hay que pedir. Objetivo: nivel al que se repone. Ambos incluyen el stock de seguridad. |
| Cant. sugerida | Objetivo menos posición, subida a cajas completas del proveedor de mejor precio. Es editable. Debajo, número de cajas e importe estimado. |
| Proveedor | El de menor precio final, con su precio y unidades por caja. |
| Aviso | Qué significa |
|---|---|
| En borrador | Ya hay cantidad de ese producto en una solicitud de presupuesto sin confirmar. Se ha sumado a la posición de stock. |
| Exceso | El stock cubre mucho más tiempo del necesario (más de 120 días y más del doble de plazo + frecuencia de pedido). |
| Sin proveedor | El producto no tiene proveedor en su ficha de Odoo. |
| Plazo teórico | No hay recepciones previas: se usa el plazo de la ficha del proveedor, o 7 días si tampoco lo tiene. |
| Sin stock | Con stock cero es probable que se hayan perdido ventas; la demanda real puede ser mayor que la calculada. |
| Pocas ventas | Menos de 3 líneas de venta en la ventana: la previsión es poco fiable. |
| Atípicos | Había pedidos extraordinariamente grandes y se han limitado para que no distorsionen la demanda. |
| Stock negativo | Odoo tiene existencias físicas negativas: conviene regularizar el inventario. |
Resumen en lenguaje llano (el detalle matemático está en la documentación técnica):
Segunda pestaña del análisis: productos con stock físico positivo y ninguna venta en la ventana analizada (del almacén elegido), ordenados por valor a coste. Sirve para detectar capital inmovilizado antes de comprar más.
Si en Odoo activáis Ubicaciones de almacenamiento (Inventario → Configuración → Ajustes) y creáis ubicaciones dentro de un almacén (por ejemplo SANE1/Stock/Pasillo-A), SANE-COMPRAS suma automáticamente el stock de todas ellas al de su almacén y detalla cada ubicación al buscar un producto. No hay que configurar nada en la app.
Referencia tecnica de SANE-COMPRAS para administradores y desarrolladores.
Servidor web Flask (Python 3.12) servido con gunicorn dentro de un contenedor Docker, detrás del proxy Caddy que aporta HTTPS (compras.saneriego.com). Se comunica con Odoo 18 por XML-RPC. El frontend es una página única en HTML/CSS/JS puro, servida por Flask. No hay base de datos ni ficheros propios: Odoo es la única fuente de verdad. El contenedor no publica puertos; solo es accesible desde Caddy por la red Docker web.
| Archivo | Función |
|---|---|
app.py | Servidor Flask: rutas API, sesiones, límite de intentos de login, protección contra pedidos duplicados y orquestación de los pasos del análisis. |
odoo_client.py | Cliente XML-RPC de Odoo (OdooClient): productos, proveedores, stock, ventas, recepciones, solicitudes en borrador y creación de pedidos. Jerarquía de excepciones propias. |
analysis.py | Motor de cálculo del análisis (analyze_replenishment(), summarize()). Procesamiento local, sin llamadas a Odoo; se puede probar con datos sintéticos. |
config.py | Configuración desde variables de entorno / .env: ODOO_URL, ODOO_DB, SECRET_KEY, ALLOWED_EMAIL_DOMAIN, SESSION_HOURS (y APP_HOST, APP_PORT, APP_DEBUG para desarrollo). |
templates/index.html | Frontend completo: HTML + CSS + JavaScript (Compras, Análisis, Documentación). |
Dockerfile, docker-compose.yml | Imagen y servicio. Usuario no root, healthcheck en /healthz, sin puertos publicados. |
.env | Solo en el servidor (no está en Git). ODOO_URL, ODOO_DB, SECRET_KEY, ALLOWED_EMAIL_DOMAIN, SESSION_HOURS. |
POST /api/login: se comprueba que el usuario es del dominio permitido (ALLOWED_EMAIL_DOMAIN, por defecto saneriego.com) y se autentica contra Odoo (xmlrpc/2/common.authenticate).SECRET_KEY, Secure, HttpOnly, SameSite=Lax) solo lleva ese identificador, nunca la contraseña.@login_required) construye un OdooClient con las credenciales de esa sesión reutilizando el uid, sin volver a autenticar.SESSION_HOURS (12) y se pierden al reiniciar el contenedor. Con SECRET_KEY fija en el .env, un reinicio no invalida las cookies válidas pero sí el estado en memoria, así que se pide login de nuevo.| Método | Ruta | Descripción |
|---|---|---|
| GET | /healthz | Comprobación de salud (sin autenticación) |
| POST | /api/login | Autenticar usuario (dominio permitido + Odoo) |
| POST | /api/logout | Cerrar sesión |
| GET | /api/session | Comprobar si hay sesión activa |
| GET | /api/test | Verificar conexión con Odoo |
| POST | /api/reconnect | Forzar reconexión |
| GET | /api/product/search?ref=XXX | Buscar producto por referencia exacta (sin distinguir mayúsculas) |
| GET | /api/product/autocomplete?q=XXX | Autocompletado por referencia, nombre, código de barras o código de proveedor |
| POST | /api/product/search-bulk | Buscar varias referencias (importación) |
| GET | /api/stock/<product_id> | Stock libre por almacén, con desglose por ubicación |
| GET | /api/suppliers/<tmpl_id> | Proveedores, precios, descuentos y mínimo de portes |
| GET | /api/history/<product_id> | Últimas compras de un producto |
| POST | /api/orders/create | Crear solicitudes de presupuesto por proveedor. Admite warehouse_id (destino) y token (anti-duplicado, 10 min) |
| POST | /api/analysis/step/sales | Paso 1: ventas (filtros: almacén, categoría). Fija la configuración del análisis |
| POST | /api/analysis/step/stock | Paso 2: stock, pronóstico de Odoo y solicitudes en borrador |
| POST | /api/analysis/step/suppliers | Paso 3: datos de producto y proveedores |
| POST | /api/analysis/step/receipts | Paso 4: recepciones para plazos reales |
| POST | /api/analysis/step/seasonal | Paso 5 (opcional): ventas del año anterior |
| POST | /api/analysis/calculate | Paso final: calcula, devuelve resultados, KPIs y stock sin movimiento |
| GET | /api/analysis/results | Último análisis de la sesión (en memoria, caduca a las 2 h) |
| GET | /api/analysis/warehouses | Almacenes (id, nombre) |
| GET | /api/analysis/categories | Categorías de producto |
| Modelo | Campos clave | Uso |
|---|---|---|
product.product | name, default_code, barcode, product_tmpl_id, is_storable, standard_price, virtual_available, qty_available, incoming_qty, outgoing_qty | Búsqueda y pronóstico de stock (con warehouse_id en el contexto para limitar a un almacén) |
product.supplierinfo | partner_id, price, discount, discount2, discount3, final_price, delay, min_qty, product_code, units_per_box | Proveedores y precios (campos de descuento y final_price son personalizados) |
res.partner | x_studio_mn_portes_gratuitos | Mínimo para portes gratis |
purchase.order | partner_id, origin, picking_type_id, order_line, state, date_approve | Crear borradores; plazos reales |
purchase.order.line | product_id, product_qty, price_unit, discount1/2/3 | Líneas de pedido; cantidades en borrador |
stock.quant | product_id, location_id, quantity, reserved_quantity | Stock libre por almacén (ubicaciones internas, incluidas las hijas) |
stock.location | warehouse_id | Asignar cada ubicación a su almacén |
stock.warehouse, stock.picking.type | name; code=incoming, warehouse_id | Almacenes y tipo de recepción de destino |
sale.order.line | product_id, product_qty (o product_uom_qty), order_id | Ventas históricas (UdM del producto; filtro por almacén de la venta) |
stock.move | product_id, date, purchase_line_id | Recepciones (plazo real) |
El carrito se agrupa por partner_id; por cada proveedor se crea un purchase.order en borrador con origin = "SANE-COMPRAS" y, si se indicó, picking_type_id = recepción del almacén de destino. Cada línea lleva:
El name usa el código del proveedor (product_code) o, si no existe, la referencia interna. Odoo calcula el subtotal aplicando los tres descuentos. Después se registra una nota en el chatter (message_post, subtipo mail.mt_note).
Protección anti-duplicados: el frontend envía un token único por clic. El servidor lo recuerda 10 minutos: un reenvío con el mismo token devuelve el resultado anterior (duplicate: true) sin crear nada; si el primero falla, se libera para poder reintentar.
Política de reposición con revisión periódica (R, s, S) y stock de seguridad estadístico. Implementada en analysis.py.
Se excluyen productos no almacenables (is_storable = False, solo Odoo 18). Los productos sin ventas en la ventana no entran en la reposición; los que tienen stock se listan en «Stock sin movimiento».
El análisis se hace en lotes (no una consulta por producto): ventas paginadas de 5.000 en 5.000; consultas por trozos de 400 ids; stock con una consulta por trozo a stock.quant; recepciones por trozos de 200 productos. Los campos opcionales (is_storable, product_qty) se detectan una vez con fields_get y se cachean. El cliente usa timeouts reales (20 s autenticación, 120 s consultas) y reutiliza el uid de la sesión. Los resultados intermedios del análisis se guardan por sesión en memoria (máx. 30 sesiones, caducan a las 2 h).
Carpeta /opt/apps/sane-compras en el servidor. Para aplicar cambios de código: docker compose up -d --build. Para cambiar la configuración (.env): docker compose up -d --force-recreate. El proxy (/opt/proxy/Caddyfile) enruta compras.saneriego.com al contenedor sane-compras-app-1:8000. Ver el estado: docker compose ps y docker compose logs -f app.
| Paquete | Versión | Uso |
|---|---|---|
flask | ≥ 3.0 | Servidor web y API REST |
gunicorn | ≥ 22 | Servidor WSGI en el contenedor |
python-dotenv | ≥ 1.0 | Carga de .env |
xmlrpc.client | (stdlib) | Comunicación con Odoo |