45 KiB
45 KiB
SPEC-PRICING-RULES: Reglas de Precios y Listas de Precios
Metadata
- Código: SPEC-MGN-007
- Módulo: Ventas / Pricing
- Gap Relacionado: GAP-MGN-007-002
- Prioridad: P1
- Esfuerzo Estimado: 13 SP
- Versión: 1.0
- Última Actualización: 2025-01-09
- Referencias: Odoo product.pricelist, product.pricelist.item
1. Resumen Ejecutivo
1.1 Objetivo
Implementar un sistema flexible de reglas de precios y listas de precios que permita:
- Precios fijos, porcentuales y basados en fórmulas
- Descuentos escalonados por cantidad (tiered pricing)
- Validez temporal de reglas (ofertas por tiempo limitado)
- Soporte multi-moneda con conversión automática
- Listas de precios en cascada (derivadas de otras)
- Códigos promocionales para e-commerce
1.2 Alcance
- Modelo de datos para pricelists y reglas
- Algoritmo de selección de reglas por prioridad
- Métodos de cálculo de precio (fixed, percentage, formula)
- Descuentos escalonados por cantidad
- Integración con órdenes de venta
- Soporte multi-moneda
- API REST para consulta de precios
1.3 Tipos de Reglas de Precio
| Tipo | Descripción | Caso de Uso |
|---|---|---|
fixed |
Precio fijo absoluto | Precios especiales, liquidaciones |
percentage |
Descuento porcentual sobre base | Descuentos por cliente/temporada |
formula |
Fórmula con markup, redondeo, márgenes | Costo + margen, precios .99 |
2. Modelo de Datos
2.1 Diagrama Entidad-Relación
┌─────────────────────────────────┐
│ pricing.pricelists │
│─────────────────────────────────│
│ id (PK) │
│ name │
│ code (promo code) │
│ currency_id (FK) │
│ company_id (FK) │
│ sequence │
│ is_active │
│ selectable │
│ country_group_ids │
└────────────────┬────────────────┘
│
│ 1:N
▼
┌─────────────────────────────────┐
│ pricing.pricelist_items │
│─────────────────────────────────│
│ id (PK) │
│ pricelist_id (FK) │
│ applied_on │
│ product_id (FK) │
│ product_tmpl_id (FK) │
│ category_id (FK) │
│ min_quantity │
│ date_start │
│ date_end │
│ compute_price │
│ base │
│ base_pricelist_id (FK) │
│ fixed_price │
│ percent_price │
│ price_discount │
│ price_markup │
│ price_round │
│ price_surcharge │
│ price_min_margin │
│ price_max_margin │
└─────────────────────────────────┘
▲
│ Referencia para cascada
│
┌─────────────────────────────────┐
│ pricing.country_groups │
│─────────────────────────────────│
│ id (PK) │
│ name │
│ country_ids │
└─────────────────────────────────┘
2.2 Definición de Tablas
2.2.1 pricing.pricelists
CREATE TABLE pricing.pricelists (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
name VARCHAR(128) NOT NULL,
code VARCHAR(32), -- Código promocional
-- Moneda
currency_id UUID NOT NULL REFERENCES core.currencies(id),
-- Empresa
company_id UUID REFERENCES core.companies(id),
-- Ordenamiento
sequence INTEGER NOT NULL DEFAULT 16,
-- Estado
is_active BOOLEAN NOT NULL DEFAULT true,
-- Disponibilidad
selectable BOOLEAN NOT NULL DEFAULT true, -- Mostrar en selección de cliente
website_only BOOLEAN NOT NULL DEFAULT false,
-- Restricciones geográficas
country_group_ids UUID[] DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES core.users(id),
-- Constraints
CONSTRAINT uk_pricelist_code UNIQUE (code) WHERE code IS NOT NULL
);
-- Índices
CREATE INDEX idx_pricelists_company ON pricing.pricelists(company_id);
CREATE INDEX idx_pricelists_currency ON pricing.pricelists(currency_id);
CREATE INDEX idx_pricelists_sequence ON pricing.pricelists(sequence) WHERE is_active;
CREATE INDEX idx_pricelists_code ON pricing.pricelists(code) WHERE code IS NOT NULL;
2.2.2 pricing.pricelist_items
CREATE TABLE pricing.pricelist_items (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
-- Lista de precios padre
pricelist_id UUID NOT NULL REFERENCES pricing.pricelists(id) ON DELETE CASCADE,
-- Ámbito de aplicación
applied_on VARCHAR(32) NOT NULL DEFAULT '3_global'
CHECK (applied_on IN (
'3_global', -- Todos los productos
'2_product_category', -- Categoría específica
'1_product', -- Producto (template)
'0_product_variant' -- Variante específica
)),
-- Filtros de producto
product_id UUID REFERENCES products.products(id),
product_tmpl_id UUID REFERENCES products.product_templates(id),
category_id UUID REFERENCES products.product_categories(id),
-- Umbral de cantidad (para descuentos escalonados)
min_quantity DECIMAL(20,6) NOT NULL DEFAULT 0,
-- Validez temporal
date_start TIMESTAMPTZ,
date_end TIMESTAMPTZ,
-- Método de cálculo
compute_price VARCHAR(16) NOT NULL DEFAULT 'fixed'
CHECK (compute_price IN ('fixed', 'percentage', 'formula')),
-- Base para el cálculo
base VARCHAR(16) NOT NULL DEFAULT 'list_price'
CHECK (base IN ('list_price', 'standard_price', 'pricelist')),
base_pricelist_id UUID REFERENCES pricing.pricelists(id),
-- Parámetros de precio fijo
fixed_price DECIMAL(20,6),
-- Parámetros de porcentaje
percent_price DECIMAL(10,4) DEFAULT 0, -- % de descuento
-- Parámetros de fórmula
price_discount DECIMAL(10,4) DEFAULT 0, -- % descuento (para list_price)
price_markup DECIMAL(10,4) DEFAULT 0, -- % markup (para cost)
price_round DECIMAL(20,6), -- Precisión de redondeo
price_surcharge DECIMAL(20,6) DEFAULT 0, -- Cargo/descuento fijo
price_min_margin DECIMAL(20,6), -- Margen mínimo
price_max_margin DECIMAL(20,6), -- Margen máximo
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT chk_date_range CHECK (date_end IS NULL OR date_start IS NULL OR date_end >= date_start),
CONSTRAINT chk_min_quantity CHECK (min_quantity >= 0),
CONSTRAINT chk_base_pricelist CHECK (
base != 'pricelist' OR base_pricelist_id IS NOT NULL
),
CONSTRAINT chk_fixed_price CHECK (
compute_price != 'fixed' OR fixed_price IS NOT NULL
),
CONSTRAINT chk_applied_on_product CHECK (
(applied_on = '0_product_variant' AND product_id IS NOT NULL) OR
(applied_on = '1_product' AND product_tmpl_id IS NOT NULL) OR
(applied_on = '2_product_category' AND category_id IS NOT NULL) OR
(applied_on = '3_global')
)
);
-- Índices optimizados para búsqueda de reglas
CREATE INDEX idx_pricelist_items_pricelist
ON pricing.pricelist_items(pricelist_id);
CREATE INDEX idx_pricelist_items_lookup
ON pricing.pricelist_items(
pricelist_id,
applied_on,
min_quantity DESC,
category_id,
id DESC
);
CREATE INDEX idx_pricelist_items_product
ON pricing.pricelist_items(product_id) WHERE product_id IS NOT NULL;
CREATE INDEX idx_pricelist_items_template
ON pricing.pricelist_items(product_tmpl_id) WHERE product_tmpl_id IS NOT NULL;
CREATE INDEX idx_pricelist_items_category
ON pricing.pricelist_items(category_id) WHERE category_id IS NOT NULL;
CREATE INDEX idx_pricelist_items_dates
ON pricing.pricelist_items(date_start, date_end)
WHERE date_start IS NOT NULL OR date_end IS NOT NULL;
2.2.3 pricing.country_groups
CREATE TABLE pricing.country_groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
name VARCHAR(64) NOT NULL,
country_ids CHAR(2)[] NOT NULL, -- Array de códigos ISO
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Datos de ejemplo
INSERT INTO pricing.country_groups (name, country_ids) VALUES
('Norteamérica', ARRAY['MX', 'US', 'CA']),
('Unión Europea', ARRAY['ES', 'FR', 'DE', 'IT', 'PT', 'NL', 'BE']),
('Latinoamérica', ARRAY['MX', 'AR', 'BR', 'CL', 'CO', 'PE']);
3. Algoritmo de Selección de Reglas
3.1 Orden de Prioridad
┌─────────────────────────────────────────────────────────────────────┐
│ PRIORIDAD DE REGLAS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. ESPECIFICIDAD (applied_on) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 0_product_variant ◄── Más específico (prioridad alta) │ │
│ │ ↓ │ │
│ │ 1_product │ │
│ │ ↓ │ │
│ │ 2_product_category │ │
│ │ ↓ │ │
│ │ 3_global ◄── Menos específico (prioridad baja) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 2. CANTIDAD MÍNIMA (min_quantity DESC) │
│ - Regla con mayor min_quantity que cumple se aplica primero │
│ - Permite descuentos escalonados │
│ │
│ 3. CATEGORÍA (category_id DESC) │
│ - Categorías más específicas primero │
│ │
│ 4. ID (id DESC) │
│ - Reglas más recientes tienen precedencia │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ REGLA: LA PRIMERA REGLA QUE COINCIDE SE APLICA │
│ ═══════════════════════════════════════════════════════════════ │
│ │
└─────────────────────────────────────────────────────────────────────┘
3.2 Pseudocódigo del Algoritmo
from decimal import Decimal
from typing import List, Optional, Tuple
from datetime import datetime
from uuid import UUID
from dataclasses import dataclass
@dataclass
class PriceResult:
"""Resultado del cálculo de precio."""
price: Decimal
rule_id: Optional[UUID]
currency_id: UUID
discount_percent: Optional[Decimal] = None
class PricingEngine:
"""
Motor de cálculo de precios basado en reglas.
"""
# Orden SQL para reglas
RULE_ORDER = """
applied_on ASC,
min_quantity DESC,
category_id DESC,
id DESC
"""
def __init__(self, db_session):
self.db = db_session
def get_product_price(
self,
pricelist_id: UUID,
product_id: UUID,
quantity: Decimal = Decimal('1'),
uom_id: Optional[UUID] = None,
date: Optional[datetime] = None,
currency_id: Optional[UUID] = None
) -> PriceResult:
"""
Obtiene el precio de un producto aplicando la lista de precios.
Args:
pricelist_id: ID de la lista de precios
product_id: ID del producto
quantity: Cantidad a cotizar
uom_id: Unidad de medida (opcional)
date: Fecha para validez de reglas
currency_id: Moneda de salida (opcional)
Returns:
PriceResult con precio y regla aplicada
"""
date = date or datetime.now()
# Obtener pricelist
pricelist = self._get_pricelist(pricelist_id)
if not pricelist:
raise ValueError(f"Pricelist {pricelist_id} no encontrada")
# Obtener producto
product = self._get_product(product_id)
if not product:
raise ValueError(f"Producto {product_id} no encontrado")
# Convertir cantidad a UoM del producto
qty_in_product_uom = self._convert_quantity(
quantity, uom_id, product['uom_id']
)
# Buscar regla aplicable
rule = self._find_applicable_rule(
pricelist_id=pricelist_id,
product=product,
quantity=qty_in_product_uom,
date=date
)
# Calcular precio
if rule:
price = self._compute_rule_price(
rule=rule,
product=product,
quantity=qty_in_product_uom,
uom_id=uom_id,
date=date
)
else:
# Sin regla: usar precio de lista
price = Decimal(str(product['list_price']))
# Convertir a UoM solicitada
if uom_id and uom_id != product['uom_id']:
price = self._convert_price_uom(price, product['uom_id'], uom_id)
# Convertir a moneda solicitada
target_currency = currency_id or pricelist['currency_id']
if pricelist['currency_id'] != target_currency:
price = self._convert_currency(
price,
pricelist['currency_id'],
target_currency,
date
)
# Calcular descuento para mostrar
discount_percent = None
if rule and rule['compute_price'] == 'percentage':
discount_percent = Decimal(str(rule['percent_price']))
return PriceResult(
price=price,
rule_id=rule['id'] if rule else None,
currency_id=target_currency,
discount_percent=discount_percent
)
def _find_applicable_rule(
self,
pricelist_id: UUID,
product: dict,
quantity: Decimal,
date: datetime
) -> Optional[dict]:
"""
Encuentra la primera regla aplicable según prioridad.
"""
# Construir dominio de búsqueda
query = """
SELECT *
FROM pricing.pricelist_items
WHERE pricelist_id = :pricelist_id
-- Filtro de fechas
AND (date_start IS NULL OR date_start <= :date)
AND (date_end IS NULL OR date_end >= :date)
-- Filtro de cantidad
AND min_quantity <= :quantity
-- Filtro de producto/categoría
AND (
-- Global
(applied_on = '3_global')
-- Categoría (incluyendo padres)
OR (applied_on = '2_product_category'
AND category_id IN (
SELECT id FROM products.product_categories
WHERE :category_path LIKE parent_path || '%'
))
-- Template
OR (applied_on = '1_product'
AND product_tmpl_id = :product_tmpl_id)
-- Variante
OR (applied_on = '0_product_variant'
AND product_id = :product_id)
)
ORDER BY {order}
LIMIT 1
""".format(order=self.RULE_ORDER)
result = self.db.execute(query, {
'pricelist_id': pricelist_id,
'date': date,
'quantity': float(quantity),
'category_path': product['category_path'],
'product_tmpl_id': product['product_tmpl_id'],
'product_id': product['id']
}).fetchone()
return dict(result) if result else None
def _compute_rule_price(
self,
rule: dict,
product: dict,
quantity: Decimal,
uom_id: Optional[UUID],
date: datetime
) -> Decimal:
"""
Aplica la regla para calcular el precio.
"""
compute_price = rule['compute_price']
if compute_price == 'fixed':
# Precio fijo
return self._apply_fixed_price(rule, product, uom_id)
elif compute_price == 'percentage':
# Descuento porcentual
base_price = self._get_base_price(rule, product, quantity, date)
percent = Decimal(str(rule['percent_price']))
return base_price * (1 - percent / 100)
elif compute_price == 'formula':
# Fórmula completa
return self._apply_formula(rule, product, quantity, uom_id, date)
# Fallback: precio de lista
return Decimal(str(product['list_price']))
def _apply_fixed_price(
self,
rule: dict,
product: dict,
uom_id: Optional[UUID]
) -> Decimal:
"""Aplica precio fijo con conversión de UoM."""
fixed_price = Decimal(str(rule['fixed_price']))
# Convertir si UoM diferente
if uom_id and uom_id != product['uom_id']:
fixed_price = self._convert_price_uom(
fixed_price, product['uom_id'], uom_id
)
return fixed_price
def _apply_formula(
self,
rule: dict,
product: dict,
quantity: Decimal,
uom_id: Optional[UUID],
date: datetime
) -> Decimal:
"""
Aplica fórmula de precio:
1. Obtener precio base
2. Aplicar descuento/markup
3. Redondear
4. Aplicar surcharge
5. Aplicar restricciones de margen
"""
# 1. Precio base
base_price = self._get_base_price(rule, product, quantity, date)
price_limit = base_price # Para cálculo de márgenes
# 2. Aplicar descuento o markup
if rule['base'] == 'standard_price':
# Para costo: usar markup (positivo = incremento)
markup = Decimal(str(rule['price_markup'] or 0))
price = base_price * (1 + markup / 100)
else:
# Para precio de lista: usar descuento (positivo = reducción)
discount = Decimal(str(rule['price_discount'] or 0))
price = base_price * (1 - discount / 100)
# 3. Redondear
if rule['price_round']:
round_precision = Decimal(str(rule['price_round']))
price = self._round_price(price, round_precision)
# 4. Aplicar surcharge (después del redondeo)
if rule['price_surcharge']:
surcharge = Decimal(str(rule['price_surcharge']))
# Convertir surcharge a UoM si es necesario
if uom_id and uom_id != product['uom_id']:
surcharge = self._convert_price_uom(
surcharge, product['uom_id'], uom_id
)
price += surcharge
# 5. Aplicar restricciones de margen
if rule['price_min_margin']:
min_margin = Decimal(str(rule['price_min_margin']))
if uom_id and uom_id != product['uom_id']:
min_margin = self._convert_price_uom(
min_margin, product['uom_id'], uom_id
)
price = max(price, price_limit + min_margin)
if rule['price_max_margin']:
max_margin = Decimal(str(rule['price_max_margin']))
if uom_id and uom_id != product['uom_id']:
max_margin = self._convert_price_uom(
max_margin, product['uom_id'], uom_id
)
price = min(price, price_limit + max_margin)
return price
def _get_base_price(
self,
rule: dict,
product: dict,
quantity: Decimal,
date: datetime
) -> Decimal:
"""
Obtiene el precio base según configuración de la regla.
"""
base = rule['base']
if base == 'pricelist':
# Precio de otra lista (cascada)
base_pricelist_id = rule['base_pricelist_id']
result = self.get_product_price(
pricelist_id=base_pricelist_id,
product_id=product['id'],
quantity=quantity,
date=date
)
return result.price
elif base == 'standard_price':
# Precio de costo
return Decimal(str(product['standard_price']))
else: # list_price
# Precio de lista
return Decimal(str(product['list_price']))
def _round_price(
self,
price: Decimal,
precision: Decimal
) -> Decimal:
"""
Redondea el precio a la precisión especificada.
Ejemplos:
- precision=0.01 → redondea a centavos
- precision=1.0 → redondea a unidad
- precision=5.0 → redondea a múltiplo de 5
- precision=10.0 → redondea a decena
"""
if precision <= 0:
return price
# Redondeo matemático estándar
return (price / precision).quantize(Decimal('1')) * precision
def get_tiered_prices(
self,
pricelist_id: UUID,
product_id: UUID,
quantities: List[Decimal],
date: Optional[datetime] = None
) -> List[dict]:
"""
Obtiene precios para múltiples cantidades (mostrar tabla de descuentos).
Returns:
Lista de {quantity, price, discount_percent, rule_id}
"""
results = []
for qty in sorted(quantities):
result = self.get_product_price(
pricelist_id=pricelist_id,
product_id=product_id,
quantity=qty,
date=date
)
# Calcular descuento sobre precio de lista
product = self._get_product(product_id)
list_price = Decimal(str(product['list_price']))
discount_pct = Decimal('0')
if list_price > 0 and result.price < list_price:
discount_pct = ((list_price - result.price) / list_price * 100
).quantize(Decimal('0.01'))
results.append({
'quantity': float(qty),
'price': float(result.price),
'discount_percent': float(discount_pct),
'rule_id': str(result.rule_id) if result.rule_id else None
})
return results
4. Métodos de Cálculo
4.1 Precio Fijo
# Configuración:
compute_price = 'fixed'
fixed_price = 99.00
# Resultado:
precio = 99.00 # Independiente del precio base
4.2 Descuento Porcentual
# Configuración:
compute_price = 'percentage'
base = 'list_price' # o 'standard_price' o 'pricelist'
percent_price = 15.0 # 15% de descuento
# Cálculo:
base_price = producto.list_price # ej: 100.00
precio = base_price * (1 - 15/100) # = 85.00
4.3 Fórmula Completa
# Configuración:
compute_price = 'formula'
base = 'list_price'
price_discount = 10.0 # 10% descuento
price_round = 5.0 # Redondear a múltiplo de 5
price_surcharge = -0.01 # Restar 1 centavo
price_min_margin = 20.0 # Margen mínimo $20
price_max_margin = 50.0 # Margen máximo $50
# Cálculo paso a paso:
base_price = 100.00 # Precio de lista
# Paso 1: Aplicar descuento
precio = 100.00 * (1 - 10/100) # = 90.00
# Paso 2: Redondear
precio = round(90.00, 5.0) # = 90.00 (ya es múltiplo de 5)
# Paso 3: Aplicar surcharge
precio = 90.00 + (-0.01) # = 89.99
# Paso 4: Verificar márgenes
# min_margin: precio >= base_price + 20 → 89.99 >= 120 → NO
# Se ajusta a margen mínimo si está por debajo
# max_margin: precio <= base_price + 50 → 89.99 <= 150 → SÍ
# Resultado final (si no hay restricción de margen activa):
precio = 89.99
5. Descuentos Escalonados (Tiered Pricing)
5.1 Configuración de Ejemplo
# Lista de precios con descuentos por volumen:
# Regla 1: 1-9 unidades (sin descuento)
{
'pricelist_id': pricelist_id,
'applied_on': '3_global',
'min_quantity': 0,
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 0
}
# Regla 2: 10-49 unidades (5% descuento)
{
'pricelist_id': pricelist_id,
'applied_on': '3_global',
'min_quantity': 10,
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 5
}
# Regla 3: 50-99 unidades (10% descuento)
{
'pricelist_id': pricelist_id,
'applied_on': '3_global',
'min_quantity': 50,
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 10
}
# Regla 4: 100+ unidades (15% descuento)
{
'pricelist_id': pricelist_id,
'applied_on': '3_global',
'min_quantity': 100,
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 15
}
5.2 Tabla de Precios Resultante
| Cantidad | Regla Aplicada | Descuento | Precio (base $100) |
|---|---|---|---|
| 1-9 | Regla 1 | 0% | $100.00 |
| 10-49 | Regla 2 | 5% | $95.00 |
| 50-99 | Regla 3 | 10% | $90.00 |
| 100+ | Regla 4 | 15% | $85.00 |
6. API REST
6.1 Endpoints
openapi: 3.0.3
info:
title: Pricing Rules API
version: 1.0.0
paths:
/api/v1/pricing/pricelists:
get:
summary: Listar listas de precios
parameters:
- name: currency_id
in: query
schema:
type: string
format: uuid
- name: is_active
in: query
schema:
type: boolean
- name: selectable
in: query
schema:
type: boolean
responses:
'200':
description: Lista de pricelists
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pricelist'
post:
summary: Crear lista de precios
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PricelistCreate'
responses:
'201':
description: Pricelist creada
/api/v1/pricing/pricelists/{id}:
get:
summary: Obtener lista de precios con reglas
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Pricelist con reglas
content:
application/json:
schema:
$ref: '#/components/schemas/PricelistDetail'
put:
summary: Actualizar lista de precios
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PricelistUpdate'
responses:
'200':
description: Pricelist actualizada
delete:
summary: Eliminar lista de precios
responses:
'204':
description: Pricelist eliminada
/api/v1/pricing/pricelists/{id}/items:
get:
summary: Listar reglas de una pricelist
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: product_id
in: query
schema:
type: string
format: uuid
- name: category_id
in: query
schema:
type: string
format: uuid
responses:
'200':
description: Lista de reglas
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/PricelistItem'
post:
summary: Crear regla de precios
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PricelistItemCreate'
responses:
'201':
description: Regla creada
/api/v1/pricing/pricelists/{id}/items/{item_id}:
put:
summary: Actualizar regla
responses:
'200':
description: Regla actualizada
delete:
summary: Eliminar regla
responses:
'204':
description: Regla eliminada
/api/v1/pricing/calculate:
post:
summary: Calcular precio de producto(s)
requestBody:
content:
application/json:
schema:
type: object
properties:
pricelist_id:
type: string
format: uuid
products:
type: array
items:
type: object
properties:
product_id:
type: string
format: uuid
quantity:
type: number
default: 1
uom_id:
type: string
format: uuid
date:
type: string
format: date-time
currency_id:
type: string
format: uuid
required:
- pricelist_id
- products
responses:
'200':
description: Precios calculados
content:
application/json:
schema:
type: object
properties:
prices:
type: array
items:
$ref: '#/components/schemas/PriceCalculation'
pricelist:
type: object
properties:
id:
type: string
name:
type: string
currency_id:
type: string
/api/v1/pricing/tiered-prices:
post:
summary: Obtener tabla de precios escalonados
requestBody:
content:
application/json:
schema:
type: object
properties:
pricelist_id:
type: string
format: uuid
product_id:
type: string
format: uuid
quantities:
type: array
items:
type: number
example: [1, 10, 50, 100]
required:
- pricelist_id
- product_id
responses:
'200':
description: Tabla de precios
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TieredPrice'
/api/v1/pricing/validate-promo-code:
post:
summary: Validar código promocional
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
partner_id:
type: string
format: uuid
required:
- code
responses:
'200':
description: Código válido
content:
application/json:
schema:
type: object
properties:
valid:
type: boolean
pricelist_id:
type: string
format: uuid
pricelist_name:
type: string
message:
type: string
components:
schemas:
Pricelist:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
code:
type: string
currency_id:
type: string
format: uuid
currency_name:
type: string
company_id:
type: string
format: uuid
sequence:
type: integer
is_active:
type: boolean
selectable:
type: boolean
item_count:
type: integer
PricelistDetail:
allOf:
- $ref: '#/components/schemas/Pricelist'
- type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/PricelistItem'
PricelistItem:
type: object
properties:
id:
type: string
format: uuid
applied_on:
type: string
enum: [3_global, 2_product_category, 1_product, 0_product_variant]
applied_on_display:
type: string
product_id:
type: string
format: uuid
product_name:
type: string
product_tmpl_id:
type: string
format: uuid
category_id:
type: string
format: uuid
category_name:
type: string
min_quantity:
type: number
date_start:
type: string
format: date-time
date_end:
type: string
format: date-time
compute_price:
type: string
enum: [fixed, percentage, formula]
base:
type: string
enum: [list_price, standard_price, pricelist]
fixed_price:
type: number
percent_price:
type: number
price_discount:
type: number
price_markup:
type: number
price_round:
type: number
price_surcharge:
type: number
price_min_margin:
type: number
price_max_margin:
type: number
PricelistItemCreate:
type: object
properties:
applied_on:
type: string
enum: [3_global, 2_product_category, 1_product, 0_product_variant]
default: 3_global
product_id:
type: string
format: uuid
product_tmpl_id:
type: string
format: uuid
category_id:
type: string
format: uuid
min_quantity:
type: number
default: 0
date_start:
type: string
format: date-time
date_end:
type: string
format: date-time
compute_price:
type: string
enum: [fixed, percentage, formula]
base:
type: string
enum: [list_price, standard_price, pricelist]
base_pricelist_id:
type: string
format: uuid
fixed_price:
type: number
percent_price:
type: number
price_discount:
type: number
price_markup:
type: number
price_round:
type: number
price_surcharge:
type: number
price_min_margin:
type: number
price_max_margin:
type: number
required:
- compute_price
PriceCalculation:
type: object
properties:
product_id:
type: string
format: uuid
quantity:
type: number
price:
type: number
currency_id:
type: string
format: uuid
rule_id:
type: string
format: uuid
discount_percent:
type: number
base_price:
type: number
description: Precio antes del descuento
TieredPrice:
type: object
properties:
quantity:
type: number
price:
type: number
discount_percent:
type: number
rule_id:
type: string
format: uuid
savings:
type: number
description: Ahorro vs precio sin descuento
7. Integración con Órdenes de Venta
7.1 Flujo de Aplicación
┌─────────────────────────────────────────────────────────────────────┐
│ FLUJO DE CÁLCULO DE PRECIO EN SO │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ │
│ │ Crear Línea de │ │
│ │ Orden de Venta │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Seleccionar │ │
│ │ Producto │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ Obtener │◄────│ Pricelist del │ │
│ │ Pricelist │ │ Cliente/Orden │ │
│ └────────┬─────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Calcular Precio │ │
│ │ (PricingEngine) │ │
│ └────────┬─────────┘ │
│ │ │
│ ├─────────────────────────────────────┐ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Asignar │ │ Calcular │ │
│ │ price_unit │ │ Descuento % │ │
│ └────────┬─────────┘ │ (si aplica) │ │
│ │ └────────┬─────────┘ │
│ │ │ │
│ └─────────────────┬───────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Recalcular │ │
│ │ Subtotal │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
7.2 Campos en Línea de Orden
# Campos calculados automáticamente:
price_unit # Precio unitario (de pricelist)
discount # Descuento % (si compute_price='percentage')
price_subtotal # price_unit * quantity * (1 - discount/100)
# Campo de referencia:
pricelist_item_id # Regla aplicada (para trazabilidad)
8. Consideraciones de Rendimiento
8.1 Índices Optimizados
-- Búsqueda de reglas por pricelist y producto
CREATE INDEX idx_pricelist_items_search
ON pricing.pricelist_items(
pricelist_id,
applied_on,
product_id,
product_tmpl_id,
category_id
);
-- Filtro por fechas activas
CREATE INDEX idx_pricelist_items_active_dates
ON pricing.pricelist_items(date_start, date_end)
WHERE date_start IS NOT NULL OR date_end IS NOT NULL;
-- Búsqueda por cantidad mínima
CREATE INDEX idx_pricelist_items_qty
ON pricing.pricelist_items(pricelist_id, min_quantity DESC);
8.2 Caching
# Cache de pricelists activas
PRICELIST_CACHE = TTLCache(maxsize=100, ttl=300) # 5 minutos
# Cache de reglas por pricelist
RULES_CACHE = TTLCache(maxsize=1000, ttl=60) # 1 minuto
# Cache de precios calculados (por sesión de cotización)
PRICE_CACHE = LRUCache(maxsize=10000)
def cache_key(pricelist_id, product_id, quantity, date):
return f"{pricelist_id}:{product_id}:{quantity}:{date.date()}"
9. Ejemplos de Configuración
9.1 Precio Mayorista (Cost + 30%)
pricelist = Pricelist.create({
'name': 'Precio Mayorista',
'currency_id': mxn_id,
'item_ids': [(0, 0, {
'applied_on': '3_global',
'compute_price': 'formula',
'base': 'standard_price', # Costo
'price_markup': 30, # +30%
})]
})
9.2 Promoción Temporal
pricelist_item = PricelistItem.create({
'pricelist_id': main_pricelist_id,
'applied_on': '2_product_category',
'category_id': electronics_id,
'compute_price': 'percentage',
'percent_price': 20, # 20% descuento
'date_start': '2025-12-01',
'date_end': '2025-12-31',
})
9.3 Precios Psicológicos (terminados en .99)
pricelist_item = PricelistItem.create({
'pricelist_id': retail_pricelist_id,
'applied_on': '3_global',
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 0,
'price_round': 10.0, # Redondear a decena
'price_surcharge': -0.01, # Restar 1 centavo
# Resultado: 100 → 100 → 99.99
})
10. Referencias
- Odoo product.pricelist model
- Odoo product.pricelist.item model
- Pricing strategies best practices
Documento generado para ERP-SUITE Versión: 1.0 Fecha: 2025-01-09