From 7a10ca9e4e28bd4dea278d655d02122753ee914c Mon Sep 17 00:00:00 2001 From: Telmo Santos Date: Thu, 25 Mar 2021 11:26:19 +0100 Subject: [PATCH 01/15] [ADD] pricelist_cache: new module --- pricelist_cache/README.rst | 97 ++++ pricelist_cache/__init__.py | 3 + pricelist_cache/__manifest__.py | 36 ++ pricelist_cache/data/base_automation.xml | 37 ++ pricelist_cache/data/demo.xml | 140 +++++ pricelist_cache/data/ir_cron.xml | 21 + pricelist_cache/data/ir_filters_data.xml | 9 + pricelist_cache/data/queue_job.xml | 15 + pricelist_cache/hooks.py | 23 + pricelist_cache/models/__init__.py | 5 + pricelist_cache/models/product_pricelist.py | 147 ++++++ .../models/product_pricelist_cache.py | 247 +++++++++ .../models/product_pricelist_item.py | 53 ++ pricelist_cache/models/product_product.py | 70 +++ pricelist_cache/models/res_partner.py | 50 ++ pricelist_cache/readme/CONTRIBUTORS.rst | 7 + pricelist_cache/readme/CREDITS.rst | 4 + pricelist_cache/readme/DESCRIPTION.rst | 11 + pricelist_cache/security/ir.model.access.csv | 3 + pricelist_cache/static/description/index.html | 449 ++++++++++++++++ pricelist_cache/tests/__init__.py | 2 + pricelist_cache/tests/common.py | 134 +++++ .../tests/test_partner_pricelist_cache.py | 22 + pricelist_cache/tests/test_pricelist_cache.py | 493 ++++++++++++++++++ pricelist_cache/views/product_pricelist.xml | 24 + .../views/product_pricelist_cache.xml | 25 + pricelist_cache/views/res_partner.xml | 26 + pricelist_cache/wizards/__init__.py | 1 + .../wizards/pricelist_cache_wizard.py | 60 +++ .../wizards/pricelist_cache_wizard.xml | 54 ++ 30 files changed, 2268 insertions(+) create mode 100644 pricelist_cache/README.rst create mode 100644 pricelist_cache/__init__.py create mode 100644 pricelist_cache/__manifest__.py create mode 100644 pricelist_cache/data/base_automation.xml create mode 100644 pricelist_cache/data/demo.xml create mode 100644 pricelist_cache/data/ir_cron.xml create mode 100644 pricelist_cache/data/ir_filters_data.xml create mode 100644 pricelist_cache/data/queue_job.xml create mode 100644 pricelist_cache/hooks.py create mode 100644 pricelist_cache/models/__init__.py create mode 100644 pricelist_cache/models/product_pricelist.py create mode 100644 pricelist_cache/models/product_pricelist_cache.py create mode 100644 pricelist_cache/models/product_pricelist_item.py create mode 100644 pricelist_cache/models/product_product.py create mode 100644 pricelist_cache/models/res_partner.py create mode 100644 pricelist_cache/readme/CONTRIBUTORS.rst create mode 100644 pricelist_cache/readme/CREDITS.rst create mode 100644 pricelist_cache/readme/DESCRIPTION.rst create mode 100644 pricelist_cache/security/ir.model.access.csv create mode 100644 pricelist_cache/static/description/index.html create mode 100644 pricelist_cache/tests/__init__.py create mode 100644 pricelist_cache/tests/common.py create mode 100644 pricelist_cache/tests/test_partner_pricelist_cache.py create mode 100644 pricelist_cache/tests/test_pricelist_cache.py create mode 100644 pricelist_cache/views/product_pricelist.xml create mode 100644 pricelist_cache/views/product_pricelist_cache.xml create mode 100644 pricelist_cache/views/res_partner.xml create mode 100644 pricelist_cache/wizards/__init__.py create mode 100644 pricelist_cache/wizards/pricelist_cache_wizard.py create mode 100644 pricelist_cache/wizards/pricelist_cache_wizard.xml diff --git a/pricelist_cache/README.rst b/pricelist_cache/README.rst new file mode 100644 index 00000000000..6d731382909 --- /dev/null +++ b/pricelist_cache/README.rst @@ -0,0 +1,97 @@ +=============== +Pricelist Cache +=============== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/14.0/pricelist_cache + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-14-0/sale-workflow-14-0-pricelist_cache + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/167/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provides a cron task who caches prices for all products and all pricelists. +The goal is to be able to generate a whole catalog of prices and products for a given customer in a decent time. + +Everyday, the cron task will trash the previous day's cache, and rebuild it from scratch. +It means that at any moment, the prices stored in the cache are those of the current day, and will not be recomputed before the next day. + +However, new prices will be cached in the following cases: + +* new product is created +* new pricelist is created +* new pricelist item is created + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Telmo Santos +* Matthieu Méquignon +* Simone Orsi +* Thierry Ducrest +* Sébastien Alix +* `Trobz `_: + * Hai Lang + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp R&D + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pricelist_cache/__init__.py b/pricelist_cache/__init__.py new file mode 100644 index 00000000000..e2087cc2c40 --- /dev/null +++ b/pricelist_cache/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from .hooks import set_default_partner_product_filter diff --git a/pricelist_cache/__manifest__.py b/pricelist_cache/__manifest__.py new file mode 100644 index 00000000000..ec24d8f29f6 --- /dev/null +++ b/pricelist_cache/__manifest__.py @@ -0,0 +1,36 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Pricelist Cache", + "summary": """ + Provide a new model to cache price lists and update it, + to make it easier to retrieve them. + """, + "version": "13.0.1.0.0", + "category": "Hidden", + "author": "Camptocamp", + "license": "AGPL-3", + "depends": [ + "base_automation", + "product", + "sale", + "queue_job", + ], + "website": "https://github.com/OCA/sale-workflow", + "data": [ + "security/ir.model.access.csv", + "data/base_automation.xml", + "data/ir_cron.xml", + "data/ir_filters_data.xml", + "data/queue_job.xml", + "views/res_partner.xml", + "views/product_pricelist.xml", + "views/product_pricelist_cache.xml", + "wizards/pricelist_cache_wizard.xml", + ], + "demo": [ + "data/demo.xml", + ], + "installable": True, + "post_init_hook": "set_default_partner_product_filter", +} diff --git a/pricelist_cache/data/base_automation.xml b/pricelist_cache/data/base_automation.xml new file mode 100644 index 00000000000..34952d4591c --- /dev/null +++ b/pricelist_cache/data/base_automation.xml @@ -0,0 +1,37 @@ + + + + Update Product Pricelist Cache + + code + + record.update_product_pricelist_cache() + on_create_or_write + + + diff --git a/pricelist_cache/data/demo.xml b/pricelist_cache/data/demo.xml new file mode 100644 index 00000000000..1b8be5235c1 --- /dev/null +++ b/pricelist_cache/data/demo.xml @@ -0,0 +1,140 @@ + + + + + + + Pricelist 0 + + + + list_price + 0_product_variant + + + 100.0 + + + + list_price + 0_product_variant + + + 100.0 + + + + + + Pricelist 1 + 2 + + + + + 3_global + formula + pricelist + + + + + + 0_product_variant + list_price + + 75.0 + + + + + Pricelist 2 + 3 + + + + + 3_global + formula + pricelist + + + + + list_price + 0_product_variant + + + 50.0 + 2021-03-01 + 2021-04-01 + + + + + Pricelist 3 + 4 + + + + + 3_global + formula + pricelist + + + + + list_price + 0_product_variant + + + 25.0 + + + + + Pricelist 4 + 5 + + + + + 3_global + formula + pricelist + + + + + list_price + 0_product_variant + + + 15.0 + + + + list_price + 0_product_variant + + + 50 + + + + + Pricelist 5 + 6 + + + + + 3_global + formula + pricelist + + 20 + + + diff --git a/pricelist_cache/data/ir_cron.xml b/pricelist_cache/data/ir_cron.xml new file mode 100644 index 00000000000..ab00e254d65 --- /dev/null +++ b/pricelist_cache/data/ir_cron.xml @@ -0,0 +1,21 @@ + + + + + + Reset pricelist cache + 1 + days + -1 + + + code + model.cron_reset_pricelist_cache() + + + + diff --git a/pricelist_cache/data/ir_filters_data.xml b/pricelist_cache/data/ir_filters_data.xml new file mode 100644 index 00000000000..bb87c3553ce --- /dev/null +++ b/pricelist_cache/data/ir_filters_data.xml @@ -0,0 +1,9 @@ + + + + Pricelist cache default product filter for partner + product.product + + + + diff --git a/pricelist_cache/data/queue_job.xml b/pricelist_cache/data/queue_job.xml new file mode 100644 index 00000000000..d0d7ce797ec --- /dev/null +++ b/pricelist_cache/data/queue_job.xml @@ -0,0 +1,15 @@ + + + + + pricelist_cache + + + + + + update_product_pricelist_cache + + + diff --git a/pricelist_cache/hooks.py b/pricelist_cache/hooks.py new file mode 100644 index 00000000000..7d8291e1a9e --- /dev/null +++ b/pricelist_cache/hooks.py @@ -0,0 +1,23 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import SUPERUSER_ID, api + + +def set_default_partner_product_filter(cr, registry): + """This hook is here because we couldn't set the default filter + as a default value for partners. + + When the module is installed, Odoo creates the new field and at the + same time tries to set the default value for all existing records in + the DB. However the XML data (and thus 'product_filter_default' filter) + is still not created at this stage. + """ + env = api.Environment(cr, SUPERUSER_ID, {}) + partners_to_update = ( + env["res.partner"] + .with_context(active_test=False) + .search([("pricelist_cache_product_filter_id", "=", False)]) + ) + default_filter = env.ref("pricelist_cache.product_filter_default") + partners_to_update.write({"pricelist_cache_product_filter_id": default_filter.id}) diff --git a/pricelist_cache/models/__init__.py b/pricelist_cache/models/__init__.py new file mode 100644 index 00000000000..1bfb3883992 --- /dev/null +++ b/pricelist_cache/models/__init__.py @@ -0,0 +1,5 @@ +from . import product_pricelist +from . import product_pricelist_item +from . import product_pricelist_cache +from . import product_product +from . import res_partner diff --git a/pricelist_cache/models/product_pricelist.py b/pricelist_cache/models/product_pricelist.py new file mode 100644 index 00000000000..25aabbb2eae --- /dev/null +++ b/pricelist_cache/models/product_pricelist.py @@ -0,0 +1,147 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from datetime import date + +from odoo import api, models + + +class Pricelist(models.Model): + _inherit = "product.pricelist" + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + for record in res: + if record._is_factor_pricelist() or record._is_global_pricelist(): + product_ids_to_cache = None + else: + product_ids_to_cache = record.item_ids.mapped("product_id").ids + cache_model = self.env["product.pricelist.cache"].with_delay() + cache_model.update_product_pricelist_cache( + product_ids=product_ids_to_cache, pricelist_ids=record.ids + ) + return res + + def _get_product_prices(self, product_ids): + self.ensure_one() + # Search instead of browse, since products could have been unlinked + # between the time where records have been created / modified + # and the time this method is executed. + products = self.env["product.product"].search([("id", "in", product_ids)]) + products_qty_partner = [(p, 1, False) for p in products] + results = self._compute_price_rule(products_qty_partner, date.today()) + product_prices = {prod: price[0] for prod, price in results.items()} + return product_prices + + def _get_root_pricelist_ids(self): + """Returns the id of all root pricelists. + + A root pricelist have no item referencing another pricelist. + """ + no_parent_query = """ + SELECT id + FROM product_pricelist pp + WHERE id NOT IN ( + SELECT pricelist_id + FROM product_pricelist_item + WHERE ( + base_pricelist_id IS NOT NULL + AND base = 'pricelist' + ) + ); + """ + self.flush() + self.env.cr.execute(no_parent_query) + return [row[0] for row in self.env.cr.fetchall()] + + def _get_factor_pricelist_ids(self): + """Returns the id of all factor pricelists. + + A factor pricelist have an item referencing a pricelist, + altering the price via price_discount or price_surcharge + """ + factor_pricelist_query = """ + SELECT id + FROM product_pricelist + WHERE id IN ( + SELECT pricelist_id + FROM product_pricelist_item + WHERE ( + base_pricelist_id IS NOT NULL + AND base = 'pricelist' + AND ( + price_discount != 0.0 + OR price_surcharge != 0.0 + ) + ) + ); + """ + self.flush() + self.env.cr.execute(factor_pricelist_query) + return [row[0] for row in self.env.cr.fetchall()] + + def _get_global_pricelist_ids(self): + """Return factor pricelists and pricelists with no parents.""" + global_pricelist_ids = self._get_root_pricelist_ids() + factor_pricelist_ids = self._get_factor_pricelist_ids() + return global_pricelist_ids + factor_pricelist_ids + + def _get_parent_pricelists(self): + """Returns the parent pricelists. + + The parent pricelist is defined on a pricelist_item when it's applied + globally, and based on another pricelist + """ + self.ensure_one() + parent_pricelist_items = self.item_ids.filtered( + lambda i: ( + i.applied_on == "3_global" + and i.base == "pricelist" + and i.base_pricelist_id + ) + ) + return parent_pricelist_items.mapped("base_pricelist_id") + + def _is_factor_pricelist(self): + """Returns whether a pricelist is a factor pricelist. + + A factor pricelist is applied globally and refers to another pricelist. + It also alters the "parent's price" by applying a discount or a surcharge + on it. + """ + self.ensure_one() + parent_pricelist_items = self.item_ids.filtered( + lambda i: ( + i.applied_on == "3_global" + and i.base == "pricelist" + and i.base_pricelist_id + and (i.price_discount or i.price_surcharge) + ) + ) + return bool(parent_pricelist_items) + + def _is_global_pricelist(self): + """Returns whether a pricelist is a factor global. + + A factor pricelist is applied globally and refers to another pricelist. + It also alters the "parent's price" by applying a discount or a surcharge + on it. + """ + self.ensure_one() + return bool(not self._get_parent_pricelists()) + + def _recursive_get_items(self, product): + """Recursively searches on parent pricelists for items applied on product.""" + item_ids = self.item_ids.filtered(lambda i: i.product_id == product).ids + for parent_pricelist in self._get_parent_pricelists(): + parent_items = parent_pricelist._recursive_get_items(product) + item_ids.extend(parent_items.ids) + return self.env["product.pricelist.item"].browse(item_ids) + + def button_open_pricelist_cache_tree(self): + cache_model = self.env["product.pricelist.cache"] + products = self.env["product.product"].search([]) + prices = cache_model.get_cached_prices_for_pricelist(self, products) + domain = [("id", "in", prices.ids)] + return cache_model._get_tree_view(domain) diff --git a/pricelist_cache/models/product_pricelist_cache.py b/pricelist_cache/models/product_pricelist_cache.py new file mode 100644 index 00000000000..1dfff5345ce --- /dev/null +++ b/pricelist_cache/models/product_pricelist_cache.py @@ -0,0 +1,247 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models, tools + + +class PricelistCache(models.Model): + """This model aims to store all product prices depending on all pricelist. + + Price cache is updated or created in the following cases: + - Product price is created / modified; + -> entrypoint "product_product.py::{create,write}" + - Pricelist item is created / modified; + -> entrypoint "product_pricelist_item.py::update_product_pricelist_cache" + - Pricelist is created; + -> entrypoint "product_pricelist.py::create" + There's also a daily cron task that updates cache prices + that have been skipped during the day: + - see "cron_update_cache_skipped_items" for the cron method + - see "product_pricelist_item.py::update_product_pricelist_cache" + for skip conditions + Every call to PricelistCache.update_product_pricelist_cache + should be made in a job as computation might be slow, depending on the case. + """ + + _name = "product.pricelist.cache" + _description = "Pricelist Cache" + _rec_name = "pricelist_id" + + pricelist_id = fields.Many2one( + "product.pricelist", + string="Pricelist", + required=True, + index=True, + ondelete="cascade", + ) + product_id = fields.Many2one( + "product.product", string="Product Variant", index=True + ) + price = fields.Float() + + def _update_existing_records(self, product_prices): + """Update existing records with provided prices. + + Args: + - self : The recordset of cache records to update + - product_prices : The new prices to apply + """ + # Write everything in single transaction + values = ",".join( + [ + "({}, {})".format(record.id, product_prices[record.product_id.id]) + for record in self + ] + ) + # pylint: disable=sql-injection + query = """ + UPDATE + product_pricelist_cache AS pricelist_cache + SET + price = c.price + FROM (VALUES {}) + AS c(id, price) + WHERE + c.id = pricelist_cache.id; + """.format( + values + ) + self.flush() + self.env.cr.execute(query) + self.invalidate_cache(["price"]) + self.recompute() + + def _create_cache_records(self, pricelist_id, product_ids, product_prices): + """Create price cache records for a given pricelist, applied to a list of + product ids. + + args: + - pricelist_id : The pricelist id on which prices are applied + - product_ids : A list of product ids to cache + - product_prices : A dict containing the prices for each product + """ + values = [ + "({}, {}, {})".format(product_id, pricelist_id, product_prices[product_id]) + for product_id in product_ids + ] + if values: + # create_everything from a single transaction + # pylint: disable=sql-injection + query = """ + INSERT INTO product_pricelist_cache (product_id, pricelist_id, price) + VALUES {}; + """.format( + ",".join(values) + ) + self.flush() + self.env.cr.execute(query) + + def _update_cache(self, pricelist_id, product_prices): + """Updates the cache, for a given pricelist, and product prices. + + Args: + - pricelist: a product.pricelist record + - product_prices: A dictionnary, + with product.product id as keys, and prices as values + """ + product_ids = list(product_prices.keys()) + # First, update existing records + existing_records = self.search( + [ + ("pricelist_id", "=", pricelist_id), + ("product_id", "in", product_ids), + ] + ) + if existing_records: + existing_records._update_existing_records(product_prices) + # Then, create missing records with provided prices + # Diff between products and already created records + not_cached_product_ids = set(product_ids) + if existing_records: + not_cached_product_ids -= set(existing_records.mapped("product_id").ids) + if not_cached_product_ids: + self._create_cache_records( + pricelist_id, not_cached_product_ids, product_prices + ) + + def _get_product_ids_to_update(self, pricelist, product_ids): + """Returns a list of product_ids that are already cached + for the given pricelist. + + Args: + - pricelist: The pricelist record on which new prices are applied + - product_prices: The list of products to check + """ + product_ids_to_update = [] + # We need to be sure to not waste resources while updating the cache. + # To do that, we ensure that prices are not coming from a parent + # pricelist. + if pricelist._get_parent_pricelists(): + # If this is a factor pricelist, then everything + # have to be updated + if pricelist._is_factor_pricelist(): + product_ids_to_update = product_ids + # Otherwise, prices are fetched from parent pricelist + # and only products in items have to be updated + else: + product_item_ids = pricelist.item_ids.filtered( + lambda i: i.product_id.id in product_ids + ) + product_ids_to_update = product_item_ids.mapped("product_id").ids + else: + # No parent (for instance public pricelist), then update everything + product_ids_to_update = product_ids + return product_ids_to_update + + def update_product_pricelist_cache(self, product_ids=None, pricelist_ids=None): + """ + Updates price list cache given a product.product recordset and a pricelist, + if specified. + """ + if not product_ids: + product_ids = self.env["product.product"].search([]).ids + if not pricelist_ids: + pricelists = self.env["product.pricelist"].search([]) + else: + # Search instead of browse, since pricelists could have been unlinked + # between the time where records have been created / modified + # and the time this method is executed. + pricelists = self.env["product.pricelist"].search( + [("id", "in", pricelist_ids)] + ) + for pricelist in pricelists: + product_ids_to_update = self._get_product_ids_to_update( + pricelist, product_ids + ) + product_prices = pricelist._get_product_prices(product_ids_to_update) + self._update_cache(pricelist.id, product_prices) + + def _update_pricelist_items_cache(self, pricelist_items): + """Updates cache for a given recordset of pricelist items, then update + the items skipped state to False. + """ + pricelist_products = pricelist_items._get_pricelist_products_group() + for pricelist_id, product_ids in pricelist_products.items(): + self.with_delay().update_product_pricelist_cache( + product_ids=product_ids, pricelist_ids=[pricelist_id] + ) + pricelist_items.write({"pricelist_cache_update_skipped": False}) + + def create_full_cache(self): + """Creates cache for all prices applied to all pricelists.""" + pricelist_ids = self.env["product.pricelist"].search([]).ids + # Spawn a job every 3 pricelists (reduce the number of jobs created) + for chunk_ids in tools.misc.split_every(3, pricelist_ids): + self.with_delay().update_product_pricelist_cache(pricelist_ids=chunk_ids) + + def cron_reset_pricelist_cache(self): + """Recreates the whole price list cache.""" + # flush table + flush_query = "TRUNCATE TABLE product_pricelist_cache CASCADE;" + self.env.cr.execute(flush_query) + # reset sequence + sequence_query = """ + ALTER SEQUENCE product_pricelist_cache_id_seq RESTART WITH 1; + """ + self.env.cr.execute(sequence_query) + # Re-create everything + self.create_full_cache() + + def get_cached_prices_for_pricelist(self, pricelist, products): + """Retrieves product prices for a given pricelist.""" + # As some items might have been skipped during product_pricelist_item + # updates, some cached prices might be wrong, since those records + # will be updated during a daily cron task. + # If any of those prices is queried here, update cache before retrieving it + need_update_items = self.env["product.pricelist.item"].search( + [ + ("pricelist_id", "=", pricelist.id), + ("product_id", "in", products.ids), + ("pricelist_cache_update_skipped", "=", True), + ] + ) + self._update_pricelist_items_cache(need_update_items) + # Retrieve cache for the current pricelist first + cached_prices = self.search( + [ + ("pricelist_id", "=", pricelist.id), + ("product_id", "in", products.ids), + ] + ) + # Then, retrieves prices from parent pricelists + remaining_products = products - cached_prices.mapped("product_id") + parent_pricelists = pricelist._get_parent_pricelists() + # There shouldn't be multiple parents for a pricelist, but it's possible… + for parent_pricelist in parent_pricelists: + cached_prices |= self.get_cached_prices_for_pricelist( + parent_pricelist, remaining_products + ) + return cached_prices + + def _get_tree_view(self, domain=None): + action = self.env.ref("pricelist_cache.product_pricelist_cache_action").read()[ + 0 + ] + if domain is not None: + action["domain"] = domain + return action diff --git a/pricelist_cache/models/product_pricelist_item.py b/pricelist_cache/models/product_pricelist_item.py new file mode 100644 index 00000000000..cb0d6102515 --- /dev/null +++ b/pricelist_cache/models/product_pricelist_item.py @@ -0,0 +1,53 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from collections import defaultdict + +from odoo import fields, models + + +class PricelistItem(models.Model): + _inherit = "product.pricelist.item" + + pricelist_cache_update_skipped = fields.Boolean() + + def _has_date_range(self): + """Returns whether any of the item records in recordset is based on dates.""" + return any(bool(record.date_start or record.date_end) for record in self) + + def _get_pricelist_products_group(self): + """Returns a mapping of products grouped by pricelist. + + Result: + keys: product.pricelist id + values: product.product list of ids + """ + pricelist_products = defaultdict(list) + for item in self: + pricelist_products[item.pricelist_id.id].append(item.product_id.id) + return pricelist_products + + def update_product_pricelist_cache(self): + """Executed when a product item is modified. Filters items not based + on variants or based on dates, then updates the cache. + """ + # Filter items applied on variants + items = self.filtered(lambda i: i.applied_on == "0_product_variant") + # Filter items based on dates + item_ids_to_update = [] + for item in items: + product_item_tree = item.pricelist_id._recursive_get_items(item.product_id) + if product_item_tree._has_date_range(): + # skip if any of the item in the tree is date based + item.pricelist_cache_update_skipped = True + continue + item_ids_to_update.append(item.id) + items_to_update = self.env["product.pricelist.item"].browse(item_ids_to_update) + # Group per pricelist + pricelist_products = items_to_update._get_pricelist_products_group() + # Update cache + cache_object = self.env["product.pricelist.cache"] + for pricelist_id, product_ids in pricelist_products.items(): + cache_object.with_delay().update_product_pricelist_cache( + product_ids=product_ids, pricelist_ids=[pricelist_id] + ) diff --git a/pricelist_cache/models/product_product.py b/pricelist_cache/models/product_product.py new file mode 100644 index 00000000000..c52be0fe41c --- /dev/null +++ b/pricelist_cache/models/product_product.py @@ -0,0 +1,70 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, models + + +class ProductProduct(models.Model): + + _inherit = "product.product" + + def write(self, vals): + """Updates pricelist cache when product price is modified. + + Only for pricelists where there's an item related to the modified product, + or when the pricelist is global. + A global pricelist have an item applied to all products is either: + - not based on a parent pricelst (i.e. public pricelist) + - based on a parent pricelist, and altering prices (i.e. factor pricelists) + -> prices are those of the parent +20% + """ + to_update_records = self.env["product.product"] + # In order to not waste resources, only update cache when price have changed + # Here, we determine if price has changed before using super() + if "list_price" in vals: + to_update_records = self.filtered( + lambda r: r.list_price != vals["list_price"] + ) + res = super().write(vals) + if to_update_records: + # Search pricelist items related to product, without fixed price + items = self.env["product.pricelist.item"].search( + [ + ("product_id", "in", to_update_records.ids), + ("compute_price", "!=", "fixed"), + ] + ) + pricelist_ids_to_update = [] + # Skip items that are based on dates + for item in items: + # get all items in pricelist hierarchy tree + items_tree = item.pricelist_id._recursive_get_items(self) + if not items_tree._has_date_range(): + pricelist_ids_to_update.append(item.pricelist_id.id) + pricelist_model = self.env["product.pricelist"] + # get global (see docstring) pricelists and add them + global_pricelist_ids = pricelist_model._get_global_pricelist_ids() + pricelist_ids_to_update.extend(global_pricelist_ids) + if pricelist_ids_to_update: + self.env[ + "product.pricelist.cache" + ].with_delay().update_product_pricelist_cache( + product_ids=to_update_records.ids, + pricelist_ids=list(set(pricelist_ids_to_update)), + ) + return res + + @api.model_create_multi + def create(self, vals): + """Create a cache record for each newly created product, for each global + pricelist. + """ + res = super().create(vals) + pricelist_model = self.env["product.pricelist"] + global_pricelist_ids = pricelist_model._get_global_pricelist_ids() + if global_pricelist_ids and res: + cache_model = self.env["product.pricelist.cache"] + cache_model.with_delay().update_product_pricelist_cache( + res.ids, global_pricelist_ids + ) + return res diff --git a/pricelist_cache/models/res_partner.py b/pricelist_cache/models/res_partner.py new file mode 100644 index 00000000000..0f008e5716f --- /dev/null +++ b/pricelist_cache/models/res_partner.py @@ -0,0 +1,50 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class Partner(models.Model): + _inherit = "res.partner" + + def _default_pricelist_cache_product_filter_id(self): + # When the module is installed, Odoo creates the new field and at the + # same time tries to set the default value for all existing records in + # the DB. However the XML data (and thus 'product_filter_default' filter) + # is still not created at this stage. + # In order to get the module installed, the 'raise_if_not_found' parameter + # has been added, and to set the default value on existing partners + # the post_init_hook 'set_default_partner_product_filter' has been defined. + return self.env.ref( + "pricelist_cache.product_filter_default", raise_if_not_found=False + ) + + pricelist_cache_product_filter_id = fields.Many2one( + comodel_name="ir.filters", + domain=[("model_id", "=", "product.product")], + default=lambda o: o._default_pricelist_cache_product_filter_id(), + ) + + def _pricelist_cache_get_prices(self): + pricelist = self._pricelist_cache_get_pricelist() + products = self._pricelist_cache_get_products() + cache_model = self.env["product.pricelist.cache"] + return cache_model.get_cached_prices_for_pricelist(pricelist, products) + + def _pricelist_cache_get_pricelist(self): + return self.property_product_pricelist + + def _pricelist_cache_get_products(self): + domain = self._pricelist_cache_product_domain() + return self.env["product.product"].search(domain) + + def _pricelist_cache_product_domain(self): + if self.pricelist_cache_product_filter_id: + return self.pricelist_cache_product_filter_id._get_eval_domain() + return [] + + def button_open_pricelist_cache_tree(self): + prices = self._pricelist_cache_get_prices() + cache_model = self.env["product.pricelist.cache"] + domain = [("id", "in", prices.ids)] + return cache_model._get_tree_view(domain) diff --git a/pricelist_cache/readme/CONTRIBUTORS.rst b/pricelist_cache/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..558f088ef03 --- /dev/null +++ b/pricelist_cache/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* Telmo Santos +* Matthieu Méquignon +* Simone Orsi +* Thierry Ducrest +* Sébastien Alix +* `Trobz `_: + * Hai Lang diff --git a/pricelist_cache/readme/CREDITS.rst b/pricelist_cache/readme/CREDITS.rst new file mode 100644 index 00000000000..4641e106617 --- /dev/null +++ b/pricelist_cache/readme/CREDITS.rst @@ -0,0 +1,4 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D diff --git a/pricelist_cache/readme/DESCRIPTION.rst b/pricelist_cache/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..5e170d456ab --- /dev/null +++ b/pricelist_cache/readme/DESCRIPTION.rst @@ -0,0 +1,11 @@ +Provides a cron task who caches prices for all products and all pricelists. +The goal is to be able to generate a whole catalog of prices and products for a given customer in a decent time. + +Everyday, the cron task will trash the previous day's cache, and rebuild it from scratch. +It means that at any moment, the prices stored in the cache are those of the current day, and will not be recomputed before the next day. + +However, new prices will be cached in the following cases: + +* new product is created +* new pricelist is created +* new pricelist item is created diff --git a/pricelist_cache/security/ir.model.access.csv b/pricelist_cache/security/ir.model.access.csv new file mode 100644 index 00000000000..45fa951ad96 --- /dev/null +++ b/pricelist_cache/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_pricelist_cache_user,access_product_pricelist_cache_user,model_product_pricelist_cache,base.group_user,1,0,0,0 +access_product_pricelist_cache_system,access_product_pricelist_cache_system,model_product_pricelist_cache,base.group_system,1,1,1,1 diff --git a/pricelist_cache/static/description/index.html b/pricelist_cache/static/description/index.html new file mode 100644 index 00000000000..f2a88f1ad6f --- /dev/null +++ b/pricelist_cache/static/description/index.html @@ -0,0 +1,449 @@ + + + + + + +Pricelist Cache + + + +
+

Pricelist Cache

+ + +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runbot

+

Provides a cron task who caches prices for all products and all pricelists. +The goal is to be able to generate a whole catalog of prices and products for a given customer in a decent time.

+

Everyday, the cron task will trash the previous day’s cache, and rebuild it from scratch. +It means that at any moment, the prices stored in the cache are those of the current day, and will not be recomputed before the next day.

+

However, new prices will be cached in the following cases:

+
    +
  • new product is created
  • +
  • new pricelist is created
  • +
  • new pricelist item is created
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp R&D
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/sale-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/pricelist_cache/tests/__init__.py b/pricelist_cache/tests/__init__.py new file mode 100644 index 00000000000..7f14111fe03 --- /dev/null +++ b/pricelist_cache/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_pricelist_cache +from . import test_partner_pricelist_cache diff --git a/pricelist_cache/tests/common.py b/pricelist_cache/tests/common.py new file mode 100644 index 00000000000..214e4123908 --- /dev/null +++ b/pricelist_cache/tests/common.py @@ -0,0 +1,134 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from functools import wraps + +from odoo.tests import SavepointCase + +LIST_PRICES_MAPPING = { + "pricelist_cache.list0": [ + {"id": 17, "price": 100.0}, + {"id": 18, "price": 79.0}, + {"id": 19, "price": 100.0}, + {"id": 20, "price": 47.0}, + ], + "pricelist_cache.list1": [ + {"id": 17, "price": 75.0}, + {"id": 18, "price": 79.0}, + {"id": 19, "price": 100.0}, + {"id": 20, "price": 47.0}, + ], + "pricelist_cache.list2": [ + {"id": 17, "price": 50.0}, + {"id": 18, "price": 79.0}, + {"id": 19, "price": 100.0}, + {"id": 20, "price": 47.0}, + ], + "pricelist_cache.list3": [ + {"id": 17, "price": 25.0}, + {"id": 18, "price": 79.0}, + {"id": 19, "price": 100.0}, + {"id": 20, "price": 47.0}, + ], + "pricelist_cache.list4": [ + {"id": 17, "price": 15.0}, + {"id": 18, "price": 50.0}, + {"id": 19, "price": 100.0}, + {"id": 20, "price": 47.0}, + ], + "pricelist_cache.list5": [ + {"id": 17, "price": 45.0}, + {"id": 18, "price": 99.0}, + {"id": 19, "price": 120.0}, + {"id": 20, "price": 67.0}, + ], +} + + +def check_duplicates(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + func(self, *args, **kwargs) + duplicates_query = """ + SELECT product_id, pricelist_id, count(*) + FROM product_pricelist_cache + GROUP BY product_id, pricelist_id + HAVING count(*) > 1; + """ + self.env.cr.execute(duplicates_query) + res = self.env.cr.fetchall() + self.assertFalse(res) + + return wrapper + + +class TestPricelistCacheCommon(SavepointCase): + @classmethod + def setUpClassBaseCache(cls): + cls.cache_model.cron_reset_pricelist_cache() + + @classmethod + def set_currency(cls): + """Sets currency everywhere, as the sale dependency breaks every unit test.""" + usd = cls.env.ref("base.USD") + cls.env.user.company_id.currency_id = usd + cls.products.write({"currency_id": usd.id}) + cls.lists.write({"currency_id": usd.id}) + cls.pricelist_items.write({"currency_id": usd.id}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + test_queue_job_no_delay=True, + ) + ) + # Odoo does not seems to register hooks by itself when tests are run + # the following line registers them explicitely + cls.env["base.automation"]._register_hook() + cls.cache_model = cls.env["product.pricelist.cache"] + # root pricelists + cls.list0 = cls.env.ref("pricelist_cache.list0") + # child 1, based on list0 + cls.list1 = cls.env.ref("pricelist_cache.list1") + # child 2, based on list1 + cls.list2 = cls.env.ref("pricelist_cache.list2") + # child 3, based on list2 + cls.list3 = cls.env.ref("pricelist_cache.list3") + # child 4, based on list0 + cls.list4 = cls.env.ref("pricelist_cache.list4") + # factor list 5, based on list3 + cls.list5 = cls.env.ref("pricelist_cache.list5") + # TODO ugly + cls.lists = cls.env["product.pricelist"].browse( + [ + cls.list0.id, + cls.list1.id, + cls.list2.id, + cls.list3.id, + cls.list4.id, + cls.list5.id, + ] + ) + # products + cls.p6 = cls.env.ref("product.product_product_6") + cls.p7 = cls.env.ref("product.product_product_7") + cls.p8 = cls.env.ref("product.product_product_8") + # P9 not in any pricelist + cls.p9 = cls.env.ref("product.product_product_9") + # TODO ugly + cls.products = cls.env["product.product"].browse( + [cls.p6.id, cls.p7.id, cls.p8.id, cls.p9.id] + ) + cls.pricelist_items = cls.env["product.pricelist.item"] + cls.pricelist_items |= cls.list0.item_ids + cls.pricelist_items |= cls.list1.item_ids + cls.pricelist_items |= cls.list2.item_ids + cls.pricelist_items |= cls.list3.item_ids + cls.pricelist_items |= cls.list4.item_ids + cls.set_currency() + cls.setUpClassBaseCache() + cls.partner = cls.env.ref("base.res_partner_12") diff --git a/pricelist_cache/tests/test_partner_pricelist_cache.py b/pricelist_cache/tests/test_partner_pricelist_cache.py new file mode 100644 index 00000000000..a48687d3b27 --- /dev/null +++ b/pricelist_cache/tests/test_partner_pricelist_cache.py @@ -0,0 +1,22 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from .common import LIST_PRICES_MAPPING, TestPricelistCacheCommon + + +@freeze_time("2021-03-15") +class TestPricelistCache(TestPricelistCacheCommon): + def test_partner_pricelists(self): + partner = self.partner + for pricelist_xmlid, expected_result in LIST_PRICES_MAPPING.items(): + partner.property_product_pricelist = self.env.ref(pricelist_xmlid) + price_list = partner._pricelist_cache_get_prices() + # for test purposes, only test products referenced in demo data + # Since cache is created for more or less products, depending + # on the modules installed + price_list = price_list.filtered(lambda p: p.product_id in self.products) + result = [{"id": c.product_id.id, "price": c.price} for c in price_list] + result.sort(key=lambda r: r["id"]) + self.assertEqual(result, expected_result) diff --git a/pricelist_cache/tests/test_pricelist_cache.py b/pricelist_cache/tests/test_pricelist_cache.py new file mode 100644 index 00000000000..04709c3b066 --- /dev/null +++ b/pricelist_cache/tests/test_pricelist_cache.py @@ -0,0 +1,493 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from .common import TestPricelistCacheCommon, check_duplicates + + +@freeze_time("2021-03-15") +class TestPricelistCache(TestPricelistCacheCommon): + @check_duplicates + def test_base_caching(self): + cache_model = self.cache_model + # product 6, list 0: cached, price 100.0 + p6_list0_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list0.id), + ] + ) + self.assertTrue(p6_list0_cache) + self.assertEqual(p6_list0_cache.price, 100.0) + # product 6, list 1: cached, price 75 + p6_list1_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list1.id), + ] + ) + self.assertTrue(p6_list1_cache) + self.assertEqual(p6_list1_cache.price, 75.0) + # product 6, list 2 : Now cached, price 50.0 + p6_list2_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list2.id), + ] + ) + self.assertTrue(p6_list2_cache) + self.assertEqual(p6_list2_cache.price, 50.0) + # product 6, list 3 : Cached, price 25.0 + p6_list3_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list3.id), + ] + ) + self.assertTrue(p6_list3_cache) + self.assertEqual(p6_list3_cache.price, 25.0) + # product 6, list 4 : Cached, price 25.0 + p6_list4_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list4.id), + ] + ) + self.assertTrue(p6_list4_cache) + self.assertEqual(p6_list4_cache.price, 15.0) + # product 6, list 5 : Cached, list3 price + 20 + p6_list3_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list3.id), + ] + ) + expected_price = p6_list3_cache.price + 20.0 + p6_list5_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list5.id), + ] + ) + self.assertTrue(p6_list5_cache) + self.assertEqual(p6_list5_cache.price, expected_price) + # product 7, list 3: cached, price 50.0 + p7_list4_cache = cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list4.id), + ] + ) + self.assertTrue(p7_list4_cache) + self.assertEqual(p7_list4_cache.price, 50.0) + # product 7, list 5 : Cached, list0 price + 20 + p7_list0_cache = cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list0.id), + ] + ) + expected_price = p7_list0_cache.price + 20.0 + p7_list5_cache = cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list5.id), + ] + ) + self.assertTrue(p7_list5_cache) + self.assertEqual(p7_list5_cache.price, expected_price) + # product 8, list 0: cached price 100.0 + p8_list0_cache = cache_model.search( + [ + ("product_id", "=", self.p8.id), + ("pricelist_id", "=", self.list0.id), + ] + ) + self.assertTrue(p8_list0_cache) + self.assertEqual(p8_list0_cache.price, 100.0) + + @check_duplicates + def test_update_pricelist_item(self): + cache_model = self.cache_model + # case 1, product price is not set on a parent pricelist + p7_list4_item = self.env.ref("pricelist_cache.item12") + p7_list4_item.fixed_price = 42.0 + p7_cache = cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list4.id), + ] + ) + self.assertEqual(p7_cache.price, 42.0) + # case 2, product price is set on the parent pricelist + p6_list4_item = self.env.ref("pricelist_cache.item11") + p6_list4_item.fixed_price = 52.0 + p6_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list4.id), + ] + ) + self.assertEqual(p6_cache.price, 52.0) + # case 3, dates are set on the item, price unchanged + p6_list2_item = self.env.ref("pricelist_cache.item7") + p6_list2_item.fixed_price = 62.0 + p6_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list2.id), + ] + ) + self.assertEqual(p6_cache.price, 50.0) + # case 4, dates are set on the parent's pricelist item: price unchanged + p6_list3_item = self.env.ref("pricelist_cache.item9") + p6_list3_item.fixed_price = 72.0 + p6_cache = cache_model.search( + [ + ("product_id", "=", self.p6.id), + ("pricelist_id", "=", self.list3.id), + ] + ) + self.assertEqual(p6_cache.price, 25.0) + + @check_duplicates + def test_update_product_price(self): + self.p7.write({"list_price": 42}) + # p6 should be updated only for list0 and list5 + p7_l0_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list0.id), + ] + ) + self.assertEqual(p7_l0_cache.price, 42) + p7_l1_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list1.id), + ] + ) + self.assertFalse(p7_l1_cache) + p7_l2_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list2.id), + ] + ) + self.assertFalse(p7_l2_cache) + p7_l3_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list3.id), + ] + ) + self.assertFalse(p7_l3_cache) + p7_l4_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list4.id), + ] + ) + self.assertEqual(p7_l4_cache.price, 50) + p7_l5_cache = self.cache_model.search( + [ + ("product_id", "=", self.p7.id), + ("pricelist_id", "=", self.list5.id), + ] + ) + self.assertEqual(p7_l5_cache.price, 62) + + @check_duplicates + def test_retrieve_price_list(self): + products = self.products + cache_model = self.cache_model + # list0 cache + l0_cache = cache_model.get_cached_prices_for_pricelist(self.list0, products) + self.assertEqual(len(l0_cache), 4) + l0_p6_cache = l0_cache.filtered(lambda c: c.product_id == self.p6) + self.assertEqual(l0_p6_cache.price, 100.0) + l0_p8_cache = l0_cache.filtered(lambda c: c.product_id == self.p8) + self.assertEqual(l0_p6_cache.price, 100.0) + # list1 cache + l1_cache = cache_model.get_cached_prices_for_pricelist(self.list1, products) + self.assertEqual(len(l1_cache), 4) + l1_p6_cache = l1_cache.filtered(lambda c: c.product_id == self.p6) + self.assertEqual(l1_p6_cache.price, 75.0) + # p8 price should have been fetched from list0 cache. + l1_p8_cache = l1_cache.filtered(lambda c: c.product_id == self.p8) + self.assertEqual(l0_p8_cache, l1_p8_cache) + # list2 cache + l2_cache = cache_model.get_cached_prices_for_pricelist(self.list2, products) + self.assertEqual(len(l2_cache), 4) + l2_p6_cache = l2_cache.filtered(lambda c: c.product_id == self.p6) + self.assertEqual(l2_p6_cache.price, 50.0) + # p8 price should have been fetched from list0 cache. + l2_p8_cache = l2_cache.filtered(lambda c: c.product_id == self.p8) + self.assertEqual(l0_p8_cache, l2_p8_cache) + # list3 cache + l3_cache = cache_model.get_cached_prices_for_pricelist(self.list3, products) + self.assertEqual(len(l3_cache), 4) + l3_p6_cache = l3_cache.filtered(lambda c: c.product_id == self.p6) + self.assertEqual(l3_p6_cache.price, 25.0) + # p8 price should have been fetched from list0 cache. + l3_p8_cache = l3_cache.filtered(lambda c: c.product_id == self.p8) + self.assertEqual(l0_p8_cache, l3_p8_cache) + # list4 cache + l4_cache = cache_model.get_cached_prices_for_pricelist(self.list4, products) + self.assertEqual(len(l4_cache), 4) + l4_p6_cache = l4_cache.filtered(lambda c: c.product_id == self.p6) + self.assertEqual(l4_p6_cache.price, 15.0) + l4_p7_cache = l4_cache.filtered(lambda c: c.product_id == self.p7) + self.assertEqual(l4_p7_cache.price, 50.0) + # p8 price should have been fetched from list0 cache. + l4_p8_cache = l4_cache.filtered(lambda c: c.product_id == self.p8) + self.assertEqual(l0_p8_cache, l4_p8_cache) + + @check_duplicates + @freeze_time("2021-04-15") + def test_retrieve_skipped_cache(self): + # When a pricelist item is updated, if it's based on dates, then the + # cache update is skipped until the next cron cache update. + # If one of those prices have to be retrieved, then the price would + # be wrong in the cache. This tests ensures that calling + # `get_cached_prices_for_pricelist` updates cache prices that + # have been skipped + item9 = self.env.ref("pricelist_cache.item9") + item9.fixed_price = 32.0 + self.assertTrue(item9.pricelist_cache_update_skipped) + item9_cache = self.env["product.pricelist.cache"].search( + [ + ("product_id", "=", item9.product_id.id), + ("pricelist_id", "=", item9.pricelist_id.id), + ] + ) + # item has been skipped, since parent item (item7) is based on dates + self.assertEqual(item9_cache.price, 25.0) + item9_cache2 = self.cache_model.get_cached_prices_for_pricelist( + item9.pricelist_id, item9.product_id + ) + # Since cache update was previously skipped, get_cache_prices_for_pricelist + # should have updated it "on the fly" + self.assertEqual(item9_cache2.price, 32.0) + self.assertFalse(item9.pricelist_cache_update_skipped) + + @check_duplicates + def test_pricelist_methods(self): + # test _get_root_pricelist_ids + pricelist_model = self.env["product.pricelist"] + expected_root_pricelist_ids = [ + self.list0.id, + self.env.ref("product.list0").id, + ].sort() + root_pricelist_ids = pricelist_model._get_root_pricelist_ids().sort() + self.assertEqual(root_pricelist_ids, expected_root_pricelist_ids) + # test _get_factor_pricelist_ids + expected_factor_pricelist_ids = self.list5.ids + factor_pricelist_ids = pricelist_model._get_factor_pricelist_ids() + self.assertEqual(factor_pricelist_ids, expected_factor_pricelist_ids) + # test _get_parent_pricelists + list_5_parent = self.list5._get_parent_pricelists() + self.assertEqual(list_5_parent, self.list3) + list_4_parent = self.list4._get_parent_pricelists() + self.assertEqual(list_4_parent, self.list0) + list_3_parent = self.list3._get_parent_pricelists() + self.assertEqual(list_3_parent, self.list2) + list_2_parent = self.list2._get_parent_pricelists() + self.assertEqual(list_2_parent, self.list1) + list_1_parent = self.list1._get_parent_pricelists() + self.assertEqual(list_1_parent, self.list0) + list_0_parent = self.list0._get_parent_pricelists() + self.assertFalse(list_0_parent) + # test _is_factor_pricelist + factor_pricelist = pricelist_model.browse(factor_pricelist_ids) + self.assertTrue(factor_pricelist._is_factor_pricelist()) + root_pricelists = pricelist_model.browse(root_pricelist_ids) + for pricelist in root_pricelists: + self.assertFalse(pricelist._is_factor_pricelist()) + # test _recursive_get_items + expected_item_ids = [ + self.env.ref("pricelist_cache.item2").id, + self.env.ref("pricelist_cache.item5").id, + self.env.ref("pricelist_cache.item7").id, + self.env.ref("pricelist_cache.item9").id, + ].sort() + items = self.list3._recursive_get_items(self.p6) + self.assertEqual(items.ids.sort(), expected_item_ids) + # test _has_date_range + self.assertTrue(items._has_date_range()) + items -= self.env.ref("pricelist_cache.item7") + self.assertFalse(items._has_date_range()) + # test _get_pricelist_products_group + expected_groups = { + self.list0.id: self.p6.ids, + self.list1.id: self.p6.ids, + self.list3.id: self.p6.ids, + } + groups = items._get_pricelist_products_group() + for ( + expected_pricelist_id, + expected_product_ids, + ) in expected_groups.items(): + self.assertEqual(expected_product_ids, groups[expected_pricelist_id]) + + @check_duplicates + @freeze_time("2021-04-15") + def test_cache_at_product_create(self): + """Ensures that cache prices are created at product creation on each global + pricelist.""" + # TODO/FIXME : As all modules are installed by travis, some fields + # that does not exists in `product` are required. + # Add the required dependencies in a future release + # # Stock is a dependency for the creation of this product + # new_product = self.env["product.product"].create( + # {"name": "Dehydrated Water", "list_price": 42} + # ) + # # global pricelist, cache created, regular price + # list0_cache = self.cache_model.search( + # [ + # ("product_id", "=", new_product.id), + # ("pricelist_id", "=", self.list0.id), + # ] + # ) + # self.assertTrue(list0_cache) + # self.assertEqual(list0_cache.price, 42) + # # Not a global pricelist, not defined + # not_global_lists_cache = self.cache_model.search( + # [ + # ("product_id", "=", new_product.id), + # ( + # "pricelist_id", + # "in", + # [ + # self.list1.id, + # self.list2.id, + # self.list3.id, + # self.list4.id, + # ], + # ), + # ] + # ) + # self.assertFalse(not_global_lists_cache) + # # Factor pricelist, defined, price +20 + # list5_cache = self.cache_model.search( + # [ + # ("product_id", "=", new_product.id), + # ("pricelist_id", "=", self.list5.id), + # ] + # ) + # self.assertTrue(list5_cache) + # self.assertEqual(list5_cache.price, 62) + + @check_duplicates + @freeze_time("2021-04-15") + def test_cache_at_pricelist_create(self): + # create pricelist child of list0, no item, no cache created + pricelist_model = self.env["product.pricelist"] + pricelist = pricelist_model.create( + { + "name": "test1", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "3_global", + "compute_price": "formula", + "base": "pricelist", + "base_pricelist_id": self.list0.id, + }, + ) + ], + } + ) + cached_prices = self.cache_model.search([("pricelist_id", "=", pricelist.id)]) + self.assertFalse(cached_prices) + # create pricelist child of list0, 1 item, 1 cache create + pricelist = pricelist_model.create( + { + "name": "test2", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "3_global", + "compute_price": "formula", + "base": "pricelist", + "base_pricelist_id": self.list0.id, + }, + ), + ( + 0, + 0, + { + "applied_on": "0_product_variant", + "base": "list_price", + "product_id": self.p6.id, + "fixed_price": 16.0, + }, + ), + ], + } + ) + cached_prices = self.cache_model.search([("pricelist_id", "=", pricelist.id)]) + self.assertEqual(len(cached_prices), 1) + self.assertEqual(cached_prices.price, 16.0) + # create factor pricelist +30, compare price with parent pricelist + pricelist = pricelist_model.create( + { + "name": "test3", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "3_global", + "compute_price": "formula", + "base": "pricelist", + "base_pricelist_id": self.list0.id, + "price_surcharge": 30, + }, + ) + ], + } + ) + self.assertTrue(pricelist._is_factor_pricelist()) + for product in self.products: + l0_cache = self.cache_model.search( + [ + ("pricelist_id", "=", self.list0.id), + ("product_id", "=", product.id), + ] + ) + cache = self.cache_model.search( + [ + ("pricelist_id", "=", pricelist.id), + ("product_id", "=", product.id), + ] + ) + self.assertEqual(l0_cache.price + 30, cache.price) + # create root pricelist, prices should be the same than those returned + # by pricelist._compute_price_rule() + pricelist = pricelist_model.create({"name": "test4"}) + self.assertTrue(pricelist._is_global_pricelist()) + product_prices = pricelist._get_product_prices(self.products.ids) + for product_id, price in product_prices.items(): + cache = self.cache_model.search( + [ + ("product_id", "=", product_id), + ("pricelist_id", "=", pricelist.id), + ] + ) + self.assertEqual(cache.price, price) + + @check_duplicates + def test_reset_cache(self): + """Ensures that the sequence is reset when cache is reset, to avoid reaching + the limit of ids, since the id is an int, with hard limit to 2,147,483,627. + """ + old_max_cache_id = max(self.cache_model.search([]).ids) + self.cache_model.cron_reset_pricelist_cache() + new_max_cache_id = max(self.cache_model.search([]).ids) + self.assertEqual(new_max_cache_id, old_max_cache_id) diff --git a/pricelist_cache/views/product_pricelist.xml b/pricelist_cache/views/product_pricelist.xml new file mode 100644 index 00000000000..9b56643881e --- /dev/null +++ b/pricelist_cache/views/product_pricelist.xml @@ -0,0 +1,24 @@ + + + + + + product.pricelist.form.inherit + product.pricelist + + + +
+
+
+
+
+ +
diff --git a/pricelist_cache/views/product_pricelist_cache.xml b/pricelist_cache/views/product_pricelist_cache.xml new file mode 100644 index 00000000000..9d284f71d7c --- /dev/null +++ b/pricelist_cache/views/product_pricelist_cache.xml @@ -0,0 +1,25 @@ + + + + + + product.pricelist.cache.tree + product.pricelist.cache + + + + + + + + + + Pricelist Cache + ir.actions.act_window + product.pricelist.cache + tree + + + + diff --git a/pricelist_cache/views/res_partner.xml b/pricelist_cache/views/res_partner.xml new file mode 100644 index 00000000000..f85a578a89a --- /dev/null +++ b/pricelist_cache/views/res_partner.xml @@ -0,0 +1,26 @@ + + + + + + res.partner.form.inherit + res.partner + + + + + + +