erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PRICING-RULES.md

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