# 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 ```sql 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 ```sql 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 ```sql 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 ```python 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 ```python # Configuración: compute_price = 'fixed' fixed_price = 99.00 # Resultado: precio = 99.00 # Independiente del precio base ``` ### 4.2 Descuento Porcentual ```python # 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 ```python # 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 ```python # 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 ```yaml 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 ```python # 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 ```sql -- 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 ```python # 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%) ```python 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 ```python 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) ```python 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