[ADD] product_sale_price_from_pricelist: módulo para calcular precio de venta desde tarifa

This commit is contained in:
snt 2026-02-11 00:34:05 +01:00
parent 123aabb775
commit 1bcc31b810
9 changed files with 712 additions and 16 deletions

View file

@ -1,6 +1,6 @@
{
"name": "Product Sale Price from Pricelist",
"version": "16.0.1.0.0",
"version": "18.0.1.0.0",
"category": "product",
"summary": "Set sale price from pricelist based on last purchase price",
"description": """
@ -11,11 +11,11 @@
"license": "AGPL-3",
"depends": [
"product_get_price_helper",
"account_invoice_triple_discount_readonly",
"sale",
"purchase",
"account",
"stock_account",
"product_template_tags",
],
"data": [
"views/actions.xml",

View file

@ -17,9 +17,10 @@ class ProductPricelist(models.Model):
ProductProduct = self.env["product.product"]
res = super()._compute_price_rule(
products,
qty=1,
qty=qty,
uom=uom,
date=date,
**kwargs
)
new_res = res.copy()
item_id = []

View file

@ -13,8 +13,8 @@ class ProductPricelistItem(models.Model):
ondelete={"last_purchase_price": "set default"},
)
def _compute_price(self, product, quantity, uom, date, currency=None):
result = super()._compute_price(product, quantity, uom, date, currency)
def _compute_price(self, product, qty, uom, date, currency=None):
result = super()._compute_price(product, qty, uom, date, currency)
if self.compute_price == "formula" and self.base == "last_purchase_price":
result = product.sudo().last_purchase_price_received
return result

View file

@ -0,0 +1,4 @@
from . import test_product_template
from . import test_stock_move
from . import test_pricelist
from . import test_res_config

View file

@ -0,0 +1,212 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestPricelist(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
# Create product
cls.product = cls.env["product.product"].create({
"name": "Test Product Pricelist",
"type": "product",
"list_price": 100.0,
"standard_price": 50.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 50.0,
"last_purchase_price_compute_type": "without_discounts",
})
# Create pricelist with last_purchase_price base
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist Last Purchase",
"currency_id": cls.env.company.currency_id.id,
})
def test_pricelist_item_base_last_purchase_price(self):
"""Test pricelist item with last_purchase_price base"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 10.0,
"applied_on": "3_global",
})
# Compute price using pricelist
price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
)
# Price should be based on last_purchase_price
self.assertIn(self.product.id, price)
# The exact price depends on the formula calculation
def test_pricelist_item_with_discount(self):
"""Test pricelist item with discount on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 20.0, # 20% discount
"applied_on": "3_global",
})
price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
)
# Expected: 50.0 - (50.0 * 0.20) = 40.0
self.assertIn(self.product.id, price)
self.assertEqual(price[self.product.id][0], 40.0)
def test_pricelist_item_with_markup(self):
"""Test pricelist item with markup on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 100.0, # 100% markup (double the price)
"applied_on": "3_global",
})
# _compute_price should return the base price (last_purchase_price_received)
result = pricelist_item._compute_price(
self.product,
qty=1,
uom=self.product.uom_id,
date=False,
currency=None
)
# Should return the last purchase price as base
self.assertEqual(result, 50.0)
def test_pricelist_item_compute_price_method(self):
"""Test _compute_price method of pricelist item"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
})
result = pricelist_item._compute_price(
self.product,
qty=1,
uom=self.product.uom_id,
date=False,
currency=None
)
# Should return last_purchase_price_received
self.assertEqual(result, self.product.last_purchase_price_received)
def test_pricelist_item_with_zero_last_purchase_price(self):
"""Test pricelist behavior when last_purchase_price is zero"""
product_zero = self.env["product.product"].create({
"name": "Product Zero Price",
"type": "product",
"list_price": 100.0,
"last_purchase_price_received": 0.0,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
})
price = self.pricelist._compute_price_rule(
product_zero, qty=1, date=False
)
# Should handle zero price gracefully
self.assertIn(product_zero.id, price)
self.assertEqual(price[product_zero.id][0], 0.0)
def test_pricelist_multiple_products(self):
"""Test pricelist calculation with multiple products"""
product2 = self.env["product.product"].create({
"name": "Test Product 2",
"type": "product",
"list_price": 200.0,
"last_purchase_price_received": 100.0,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
})
products = self.product | product2
# Test with both products
for product in products:
price = self.pricelist._compute_price_rule(
product, qty=1, date=False
)
self.assertIn(product.id, price)
def test_pricelist_item_selection_add(self):
"""Test that last_purchase_price is added to base selection"""
pricelist_item_model = self.env["product.pricelist.item"]
base_field = pricelist_item_model._fields["base"]
# Check that last_purchase_price is in the selection
selection_values = [item[0] for item in base_field.selection]
self.assertIn("last_purchase_price", selection_values)
def test_product_price_compute_fallback(self):
"""Test price_compute method fallback for last_purchase_price"""
result = self.product.price_compute("last_purchase_price")
# Should return dummy value 1.0 for all products
self.assertEqual(result[self.product.id], 1.0)
def test_pricelist_with_different_currencies(self):
"""Test pricelist with different currency (if applicable)"""
# This test is optional and depends on multi-currency setup
eur = self.env.ref("base.EUR", raise_if_not_found=False)
if not eur:
self.skipTest("EUR currency not available")
pricelist_eur = self.env["product.pricelist"].create({
"name": "Test Pricelist EUR",
"currency_id": eur.id,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": pricelist_eur.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"applied_on": "3_global",
})
# Price calculation should work with different currency
price = pricelist_eur._compute_price_rule(
self.product, qty=1, date=False
)
self.assertIn(self.product.id, price)

View file

@ -0,0 +1,163 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
@tagged("post_install", "-at_install")
class TestProductTemplate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create a tax for the product
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
# Create a pricelist with formula based on cost
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist Automatic",
"currency_id": cls.env.company.currency_id.id,
})
cls.pricelist_item = cls.env["product.pricelist.item"].create({
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 0,
"price_markup": 50.0, # 50% markup
"applied_on": "3_global",
})
# Set the pricelist in configuration
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id)
)
# Create a product category
cls.category = cls.env["product.category"].create({
"name": "Test Category",
})
# Create a product
cls.product = cls.env["product.template"].create({
"name": "Test Product",
"type": "product",
"categ_id": cls.category.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
def test_compute_theoritical_price_without_tax_error(self):
"""Test that computing theoretical price without taxes raises an error"""
product_no_tax = self.env["product.template"].create({
"name": "Product No Tax",
"type": "product",
"list_price": 10.0,
"standard_price": 5.0,
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
with self.assertRaises(UserError):
product_no_tax._compute_theoritical_price()
def test_compute_theoritical_price_success(self):
"""Test successful computation of theoretical price"""
self.product._compute_theoritical_price()
# Verify that theoretical price was calculated
self.assertGreater(self.product.list_price_theoritical, 0)
# Verify that the price includes markup and tax
# With 50% markup on 5.0 = 7.5, plus 21% tax = 9.075, rounded to 9.10
self.assertAlmostEqual(self.product.list_price_theoritical, 9.10, places=2)
def test_compute_theoritical_price_with_rounding(self):
"""Test that prices are rounded to 0.05"""
self.product.last_purchase_price_received = 4.0
self.product._compute_theoritical_price()
# Price should be rounded to nearest 0.05
price = self.product.list_price_theoritical
self.assertEqual(round(price % 0.05, 2), 0.0)
def test_last_purchase_price_updated_flag(self):
"""Test that the updated flag is set when prices differ"""
initial_list_price = self.product.list_price
self.product.last_purchase_price_received = 10.0
self.product._compute_theoritical_price()
if self.product.list_price_theoritical != initial_list_price:
self.assertTrue(self.product.last_purchase_price_updated)
def test_action_update_list_price(self):
"""Test updating list price from theoretical price"""
self.product.last_purchase_price_received = 8.0
self.product._compute_theoritical_price()
theoretical_price = self.product.list_price_theoritical
self.product.action_update_list_price()
# Verify that list price was updated
self.assertEqual(self.product.list_price, theoretical_price)
self.assertFalse(self.product.last_purchase_price_updated)
def test_manual_update_type_skips_automatic(self):
"""Test that manual update type prevents automatic price calculation"""
self.product.last_purchase_price_compute_type = "manual_update"
initial_list_price = self.product.list_price
self.product.action_update_list_price()
# List price should not change for manual update type
self.assertEqual(self.product.list_price, initial_list_price)
def test_price_compute_with_last_purchase_price(self):
"""Test price_compute method with last_purchase_price type"""
result = self.product.price_compute("last_purchase_price")
# Should return dummy prices (1.0) for all product ids
for product_id in result:
self.assertEqual(result[product_id], 1.0)
def test_compute_theoritical_price_without_pricelist(self):
"""Test that missing pricelist raises an error"""
# Remove pricelist configuration
self.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
""
)
with self.assertRaises(UserError):
self.product._compute_theoritical_price()
# Restore pricelist configuration for other tests
self.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(self.pricelist.id)
)
def test_company_dependent_fields(self):
"""Test that price fields are company dependent"""
# Verify field properties
field_last_purchase = self.product._fields["last_purchase_price_received"]
field_theoritical = self.product._fields["list_price_theoritical"]
field_updated = self.product._fields["last_purchase_price_updated"]
field_compute_type = self.product._fields["last_purchase_price_compute_type"]
self.assertTrue(field_last_purchase.company_dependent)
self.assertTrue(field_theoritical.company_dependent)
self.assertTrue(field_updated.company_dependent)
self.assertTrue(field_compute_type.company_dependent)

View file

@ -0,0 +1,78 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestResConfigSettings(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Config Pricelist",
"currency_id": cls.env.company.currency_id.id,
})
def test_config_parameter_set_and_get(self):
"""Test setting and getting pricelist configuration"""
config = self.env["res.config.settings"].create({
"product_pricelist_automatic": self.pricelist.id,
})
config.execute()
# Verify parameter was saved
saved_id = self.env["ir.config_parameter"].sudo().get_param(
"product_sale_price_from_pricelist.product_pricelist_automatic"
)
self.assertEqual(int(saved_id), self.pricelist.id)
def test_config_load_from_parameter(self):
"""Test loading pricelist from config parameter"""
# Set parameter directly
self.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(self.pricelist.id)
)
# Create config and check if value is loaded
config = self.env["res.config.settings"].create({})
self.assertEqual(config.product_pricelist_automatic.id, self.pricelist.id)
def test_config_update_pricelist(self):
"""Test updating pricelist configuration"""
# Set initial pricelist
config = self.env["res.config.settings"].create({
"product_pricelist_automatic": self.pricelist.id,
})
config.execute()
# Create new pricelist and update
new_pricelist = self.env["product.pricelist"].create({
"name": "New Config Pricelist",
"currency_id": self.env.company.currency_id.id,
})
config2 = self.env["res.config.settings"].create({
"product_pricelist_automatic": new_pricelist.id,
})
config2.execute()
# Verify new value
saved_id = self.env["ir.config_parameter"].sudo().get_param(
"product_sale_price_from_pricelist.product_pricelist_automatic"
)
self.assertEqual(int(saved_id), new_pricelist.id)
def test_config_without_pricelist(self):
"""Test configuration can be saved without pricelist"""
config = self.env["res.config.settings"].create({})
# Should not raise error
config.execute()

View file

@ -0,0 +1,244 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestStockMove(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
# Create pricelist
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist",
"currency_id": cls.env.company.currency_id.id,
})
cls.pricelist_item = cls.env["product.pricelist.item"].create({
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
})
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id)
)
# Create supplier
cls.supplier = cls.env["res.partner"].create({
"name": "Test Supplier",
"supplier_rank": 1,
})
# Create product with UoM
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
cls.product = cls.env["product.product"].create({
"name": "Test Product Stock",
"type": "product",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
# Create locations
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
cls.stock_location = cls.env.ref("stock.stock_location_stock")
def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0):
"""Helper to create a purchase order"""
purchase_order = self.env["purchase.order"].create({
"partner_id": self.supplier.id,
})
po_line = self.env["purchase.order.line"].create({
"order_id": purchase_order.id,
"product_id": product.id,
"product_qty": qty,
"price_unit": price,
"product_uom": product.uom_po_id.id,
})
# Add discounts if module supports it
if hasattr(po_line, "discount1"):
po_line.discount1 = discount1
if hasattr(po_line, "discount2"):
po_line.discount2 = discount2
if hasattr(po_line, "discount3"):
po_line.discount3 = discount3
return purchase_order
def test_update_price_without_discounts(self):
"""Test price update without discounts"""
purchase_order = self._create_purchase_order(
self.product, qty=10, price=8.0
)
purchase_order.button_confirm()
# Get the picking and process it
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Verify price was updated
self.assertEqual(self.product.last_purchase_price_received, 8.0)
def test_update_price_with_first_discount(self):
"""Test price update with first discount only"""
if not hasattr(self.env["purchase.order.line"], "discount1"):
self.skipTest("Purchase discount module not installed")
self.product.last_purchase_price_compute_type = "with_discount"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Expected: 10.0 * (1 - 0.20) = 8.0
self.assertEqual(self.product.last_purchase_price_received, 8.0)
def test_update_price_with_two_discounts(self):
"""Test price update with two discounts"""
if not hasattr(self.env["purchase.order.line"], "discount2"):
self.skipTest("Purchase double discount module not installed")
self.product.last_purchase_price_compute_type = "with_two_discounts"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Expected: 10.0 * (1 - 0.20) * (1 - 0.10) = 7.2
self.assertEqual(self.product.last_purchase_price_received, 7.2)
def test_update_price_with_three_discounts(self):
"""Test price update with three discounts"""
if not hasattr(self.env["purchase.order.line"], "discount3"):
self.skipTest("Purchase triple discount module not installed")
self.product.last_purchase_price_compute_type = "with_three_discounts"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Price should be calculated from subtotal / qty
# Subtotal with all discounts applied
def test_update_price_with_uom_conversion(self):
"""Test price update with different purchase UoM"""
# Create product with different purchase UoM
product_dozen = self.product.copy({
"name": "Test Product Dozen",
"uom_po_id": self.uom_dozen.id,
})
purchase_order = self._create_purchase_order(
product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Price should be converted to base UoM (unit)
# 120.0 per dozen = 10.0 per unit
self.assertEqual(product_dozen.last_purchase_price_received, 10.0)
def test_no_update_with_zero_quantity(self):
"""Test that price is not updated with zero quantity done"""
initial_price = self.product.last_purchase_price_received
purchase_order = self._create_purchase_order(
self.product, qty=10, price=15.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
# Set quantity done to 0
for move in picking.move_ids:
move.quantity = 0
# This should not update the price
# Price should remain unchanged
self.assertEqual(self.product.last_purchase_price_received, initial_price)
def test_manual_update_type_no_automatic_update(self):
"""Test that manual update type prevents automatic price updates"""
self.product.last_purchase_price_compute_type = "manual_update"
initial_price = self.product.last_purchase_price_received
purchase_order = self._create_purchase_order(
self.product, qty=10, price=15.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# For manual update, the standard Odoo behavior applies
# which may or may not update standard_price, but our custom
# last_purchase_price_received logic still runs

View file

@ -6,15 +6,12 @@
<record model="ir.ui.view" id="product_view_inherit_list_price_auto">
<field name="name">product.list.price.automatic.form</field>
<field name="model">product.template</field>
<field name="type">form</field>
<field name="inherit_id" ref="product.product_template_form_view" />
<field name="arch" type="xml">
<field name="standard_price" position="attributes">
<attribute name="attrs">
{'readonly':[('last_purchase_price_compute_type','!=','manual_update')]}
</attribute>
<attribute name="readonly">last_purchase_price_compute_type != 'manual_update'</attribute>
</field>
<field name="product_tag_ids" position="after">
<field name="list_price" position="after">
<separator string="Label Info" colspan="2"/>
<field name="last_purchase_price_compute_type" />
<field name="last_purchase_price_received"
@ -46,16 +43,13 @@
<field name="priority" eval="100" />
<field name="inherit_id" ref="sale.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//div[@id='pricing_setting_container']" position="inside">
<div
class="col-12 col-lg-6 o_setting_box"
name="supermarket_settigs_container"
>
<xpath expr="//block[@id='pricing_setting_container']" position="inside">
<setting name="supermarket_settings_container">
<div class="o_setting_right_pane">
<label for="product_pricelist_automatic" string="Sale Price Pricelist" />
<field name="product_pricelist_automatic" />
</div>
</div>
</setting>
</xpath>
</field>
</record>