diff --git a/product_pricelist_supplier_info/__init__.py b/product_pricelist_supplier_info/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/product_pricelist_supplier_info/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_pricelist_supplier_info/__manifest__.py b/product_pricelist_supplier_info/__manifest__.py new file mode 100644 index 00000000..7e454152 --- /dev/null +++ b/product_pricelist_supplier_info/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2025 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +{ + "name": "Product Pricelist Supplier Info", + "summary": "Show supplier info on pricelist item", + "version": "14.0.1.0.0", + "development_status": "Alpha", + "category": "Uncategorized", + "website": "www.akretion.com", + "author": " Akretion", + "license": "AGPL-3", + "external_dependencies": { + "python": [], + "bin": [], + }, + "depends": [ + "purchase", + "sale", + "product_supplierinfo_per_attribute_value", + "product_pricelist_per_attribute_value", + ], + "data": ["views/product_pricelist_item_view.xml"], + "demo": [], +} diff --git a/product_pricelist_supplier_info/i18n/fr.po b/product_pricelist_supplier_info/i18n/fr.po new file mode 100644 index 00000000..9c15b948 --- /dev/null +++ b/product_pricelist_supplier_info/i18n/fr.po @@ -0,0 +1,54 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_pricelist_supplier_info +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__display_name +msgid "Display Name" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__id +msgid "ID" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item____last_update +msgid "Last Modified on" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_info +msgid "Main Supplier Info" +msgstr "Autre Prix" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_margin +msgid "Main Supplier Margin" +msgstr "Marge" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_price +msgid "Main Supplier Price" +msgstr "Prix Fournisseur" + +#. module: product_pricelist_supplier_info +#: model:ir.model,name:product_pricelist_supplier_info.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_partner_id +msgid "Supplier" +msgstr "Fournisseur" diff --git a/product_pricelist_supplier_info/i18n/product_pricelist_supplier_info.pot b/product_pricelist_supplier_info/i18n/product_pricelist_supplier_info.pot new file mode 100644 index 00000000..2b644ed6 --- /dev/null +++ b/product_pricelist_supplier_info/i18n/product_pricelist_supplier_info.pot @@ -0,0 +1,54 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_pricelist_supplier_info +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__display_name +msgid "Display Name" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__id +msgid "ID" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item____last_update +msgid "Last Modified on" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_info +msgid "Main Supplier Info" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_margin +msgid "Main Supplier Margin" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_price +msgid "Main Supplier Price" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model,name:product_pricelist_supplier_info.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "" + +#. module: product_pricelist_supplier_info +#: model:ir.model.fields,field_description:product_pricelist_supplier_info.field_product_pricelist_item__main_supplier_partner_id +msgid "Supplier" +msgstr "" diff --git a/product_pricelist_supplier_info/models/__init__.py b/product_pricelist_supplier_info/models/__init__.py new file mode 100644 index 00000000..41c51bd2 --- /dev/null +++ b/product_pricelist_supplier_info/models/__init__.py @@ -0,0 +1 @@ +from . import product_pricelist_item diff --git a/product_pricelist_supplier_info/models/product_pricelist_item.py b/product_pricelist_supplier_info/models/product_pricelist_item.py new file mode 100644 index 00000000..421c8aab --- /dev/null +++ b/product_pricelist_supplier_info/models/product_pricelist_item.py @@ -0,0 +1,84 @@ +# Copyright 2025 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductPricelistItem(models.Model): + _inherit = "product.pricelist.item" + + main_supplier_partner_id = fields.Many2one( + "res.partner", "Supplier", compute="_compute_supplier_info" + ) + main_supplier_margin = fields.Float(compute="_compute_supplier_info") + main_supplier_price = fields.Float(compute="_compute_supplier_info") + main_supplier_info = fields.Text(compute="_compute_supplier_info") + + def _prepare_info_name(self, seller): + tmpl_values = seller.product_id.product_template_attribute_value_ids + values = ( + tmpl_values.product_attribute_value_id or seller.product_attribute_value_ids + ) + if values: + return ", ".join(values.mapped("name")) + else: + return "_" + + @api.depends( + "product_tmpl_id.seller_ids.price", + "product_tmpl_id.seller_ids.name", + "product_tmpl_id.seller_ids.min_qty", + "product_tmpl_id.seller_ids.date_start", + "product_tmpl_id.seller_ids.date_end", + "product_tmpl_id.seller_ids.sequence", + "product_tmpl_id.seller_ids.product_id", + "product_tmpl_id.seller_ids.product_attribute_value_ids", # in a glue module ? + ) + def _compute_supplier_info(self): + # Following code is not optimal when having a lot of variante + # But it's simple code ;) + for record in self: + sellers = self.env["product.supplierinfo"] + if record.product_id: + sellers = record.product_id._select_seller(quantity=record.min_quantity) + elif record.product_tmpl_id: + for variant in record.product_tmpl_id.product_variant_ids: + if record._is_applicable_for(variant, record.min_quantity): + sellers |= variant._select_seller(quantity=record.min_quantity) + if not sellers: + record.update( + { + "main_supplier_partner_id": None, + "main_supplier_margin": 0, + "main_supplier_price": 0, + "main_supplier_info": "", + } + ) + else: + sellers = sellers.sorted("price", reverse=True) + seller = sellers[0] + if len(sellers) > 1 and seller.price > sellers[-1].price: + info = "- " + "\n- ".join( + [ + ( + format(seller.price, "g") + + f" : {self._prepare_info_name(seller)}" + ) + for seller in sellers + ] + ) + else: + info = "" + if seller.price and record.fixed_price: + margin = (record.fixed_price - seller.price) / record.fixed_price + else: + margin = 0 + record.update( + { + "main_supplier_partner_id": seller.name.id, + "main_supplier_margin": margin, + "main_supplier_price": seller.price, + "main_supplier_info": info, + } + ) diff --git a/product_pricelist_supplier_info/models/product_supplierinfo.py b/product_pricelist_supplier_info/models/product_supplierinfo.py new file mode 100644 index 00000000..9fc5e052 --- /dev/null +++ b/product_pricelist_supplier_info/models/product_supplierinfo.py @@ -0,0 +1,13 @@ +# Copyright 2025 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import _, api, fields, models + + +class ProductSupplierinfo(models.Model): + _inherit = 'product.supplierinfo' + + + diff --git a/product_pricelist_supplier_info/tests/__init__.py b/product_pricelist_supplier_info/tests/__init__.py new file mode 100644 index 00000000..ed0f7849 --- /dev/null +++ b/product_pricelist_supplier_info/tests/__init__.py @@ -0,0 +1 @@ +from . import test_price diff --git a/product_pricelist_supplier_info/tests/test_price.py b/product_pricelist_supplier_info/tests/test_price.py new file mode 100644 index 00000000..ca6f5bd8 --- /dev/null +++ b/product_pricelist_supplier_info/tests/test_price.py @@ -0,0 +1,240 @@ +# Copyright 2025 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.tests import SavepointCase + + +class TestPrice(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Supplier"}) + size, color = cls.env["product.attribute"].create( + [{"name": "T Size"}, {"name": "T Color"}] + ) + c_blue, c_green, c_red, s_m, s_l, s_xl = cls.env[ + "product.attribute.value" + ].create( + [ + {"attribute_id": color.id, "name": "Blue"}, + {"attribute_id": color.id, "name": "Green"}, + {"attribute_id": color.id, "name": "Red"}, + {"attribute_id": size.id, "name": "M"}, + {"attribute_id": size.id, "name": "L"}, + {"attribute_id": size.id, "name": "XL"}, + ] + ) + cls.c_green = c_green + cls.c_red = c_red + + cls.t_shirt = cls.env["product.template"].create( + { + "name": "T-shirt", + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": size.id, + "value_ids": [s_m.id, s_l.id, s_xl.id], + }, + ), + ( + 0, + 0, + { + "attribute_id": color.id, + "value_ids": [c_blue.id, c_green.id, c_red.id], + }, + ), + ], + } + ) + + def get_product(values): + for variant in cls.t_shirt.product_variant_ids: + tmpl_values = variant.product_template_attribute_value_ids + if tmpl_values.product_attribute_value_id == values: + return variant + + cls.t_shirt_green_xl = get_product(s_xl | c_green) + cls.t_shirt_green_l = get_product(s_l | c_green) + cls.t_shirt_red_l = get_product(s_l | c_red) + cls.t_shirt.write( + { + "seller_ids": [ + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "product_id": cls.t_shirt_green_xl.id, + "price": 100, + }, + ), + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "product_id": cls.t_shirt_green_xl.id, + "price": 50, + "min_qty": 100, + }, + ), + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "product_attribute_value_ids": [(6, 0, [c_green.id])], + "price": 90, + }, + ), + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "product_attribute_value_ids": [(6, 0, [c_green.id])], + "price": 45, + "min_qty": 100, + }, + ), + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "price": 80, + }, + ), + ( + 0, + 0, + { + "name": cls.partner.id, + "product_tmpl_id": cls.t_shirt.id, + "price": 40, + "min_qty": 100, + }, + ), + ] + } + ) + cls.pricelist = cls.env["product.pricelist"].create( + {"name": "Price per product"} + ) + + def _create_price_item(self, list_vals): + for vals in list_vals: + if vals.get("product"): + vals["product_id"] = vals.pop("product").id + if vals.get("attr_values"): + vals["product_attribute_value_ids"] = [ + (6, 0, vals.pop("attr_values").ids) + ] + vals["fixed_price"] = vals.pop("price") + vals.update( + { + "pricelist_id": self.pricelist.id, + "product_tmpl_id": self.t_shirt.id, + } + ) + return self.env["product.pricelist.item"].create(list_vals) + + def _assert_item(self, item, price, info=""): + self.assertEqual(item.main_supplier_partner_id, self.partner) + self.assertEqual(item.main_supplier_price, price) + self.assertEqual(item.main_supplier_info, info) + + def test_pricelist_product(self): + ( + green_xl, + green_xl_50, + green_xl_100, + green_xl_200, + green_l, + green_l_100, + red_l, + red_l_100, + ) = self._create_price_item( + [ + {"product": self.t_shirt_green_xl, "price": 200}, + {"product": self.t_shirt_green_xl, "price": 180, "min_quantity": 50}, + {"product": self.t_shirt_green_xl, "price": 160, "min_quantity": 100}, + {"product": self.t_shirt_green_xl, "price": 140, "min_quantity": 200}, + {"product": self.t_shirt_green_l, "price": 180}, + {"product": self.t_shirt_green_l, "price": 140, "min_quantity": 100}, + {"product": self.t_shirt_red_l, "price": 160}, + {"product": self.t_shirt_red_l, "price": 120, "min_quantity": 100}, + ] + ) + # check margin once + self.assertEqual(green_xl.main_supplier_margin, 100) + + self._assert_item(green_xl, 100) + self._assert_item(green_xl_50, 100) + self._assert_item(green_xl_100, 50) + self._assert_item(green_xl_200, 50) + self._assert_item(green_l, 90) + self._assert_item(green_l_100, 45) + self._assert_item(red_l, 80) + self._assert_item(red_l_100, 40) + + def test_pricelist_attribute(self): + # vert => info + mx price + # rouge > template + # rouge + vert => info + max price + c_green = self.c_green + c_red = self.c_red + ( + green, + green_50, + green_100, + green_200, + red, + red_100, + red_green, + red_green_100, + ) = self._create_price_item( + [ + {"attr_values": c_green, "price": 200}, + {"attr_values": c_green, "price": 180, "min_quantity": 50}, + {"attr_values": c_green, "price": 160, "min_quantity": 100}, + {"attr_values": c_green, "price": 140, "min_quantity": 200}, + {"attr_values": c_red, "price": 180}, + {"attr_values": c_red, "price": 140, "min_quantity": 100}, + {"attr_values": c_red | c_green, "price": 160}, + {"attr_values": c_red | c_green, "price": 120, "min_quantity": 100}, + ] + ) + self._assert_item(green, 100, "- XL, Green: 100\n- Green: 90") + self._assert_item(green_50, 100, "- XL, Green: 100\n- Green: 90") + self._assert_item(green_100, 50, "- XL, Green: 50\n- Green: 45") + self._assert_item(green_200, 50, "- XL, Green: 50\n- Green: 45") + self._assert_item(red, 80) + self._assert_item(red_100, 40) + self._assert_item( + red_green, 100, "- XL, Green: 100\n- Green: 90\n- _: 80") + self._assert_item( + red_green_100, 50, "- XL, Green: 50\n- Green: 45\n- _: 40") + + def test_pricelist_template(self): + qty_1, qty_50, qty_100, qty_200 = self._create_price_item([ + {"price": 200}, + {"price": 180, "min_quantity": 50}, + {"price": 160, "min_quantity": 100}, + {"price": 140, "min_quantity": 200}, + ]) + self._assert_item(qty_1, 100, "- XL, Green: 100\n- Green: 90\n- _: 80") + self._assert_item(qty_50, 100, "- XL, Green: 100\n- Green: 90\n- _: 80") + self._assert_item(qty_100, 50, "- XL, Green: 50\n- Green: 45\n- _: 40") + self._assert_item(qty_200, 50, "- XL, Green: 50\n- Green: 45\n- _: 40") diff --git a/product_pricelist_supplier_info/views/product_pricelist_item_view.xml b/product_pricelist_supplier_info/views/product_pricelist_item_view.xml new file mode 100644 index 00000000..bfb16616 --- /dev/null +++ b/product_pricelist_supplier_info/views/product_pricelist_item_view.xml @@ -0,0 +1,21 @@ + + + + + product.pricelist.item + + + + + + + + + + + + diff --git a/product_supplierinfo_per_attribute_value/models/product_supplierinfo_attribute_mixin.py b/product_supplierinfo_per_attribute_value/models/product_supplierinfo_attribute_mixin.py index b2c9b2a6..70726846 100644 --- a/product_supplierinfo_per_attribute_value/models/product_supplierinfo_attribute_mixin.py +++ b/product_supplierinfo_per_attribute_value/models/product_supplierinfo_attribute_mixin.py @@ -36,6 +36,14 @@ def _compute_allowed_attribute_value_ids(self): @api.depends("product_tmpl_id", "product_attribute_value_ids", "product_id") def _compute_product_definition_precision(self): + # Product definition have kind of the same behaviour as we have on + # the pricelist item take the price from + # specific rule (on the product) + # then based on the attribute + # and if nothing match use the generic rule of the supplier + # Native odoo just take the best price so if you define a price + # on a variant and on the template if the price on the template + # is the less expensive it will always take it for record in self: if record.product_id: record.product_definition_precision = 9999 diff --git a/setup/product_pricelist_supplier_info/odoo/addons/product_pricelist_supplier_info b/setup/product_pricelist_supplier_info/odoo/addons/product_pricelist_supplier_info new file mode 120000 index 00000000..a4aaf3bc --- /dev/null +++ b/setup/product_pricelist_supplier_info/odoo/addons/product_pricelist_supplier_info @@ -0,0 +1 @@ +../../../../product_pricelist_supplier_info \ No newline at end of file diff --git a/setup/product_pricelist_supplier_info/setup.py b/setup/product_pricelist_supplier_info/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/product_pricelist_supplier_info/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)