Initial commit: Odoo 18.0-20251222 extra-addons
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
tocmo0nlord
2026-03-13 20:43:25 +00:00
parent 36e847a7df
commit adbe430761
9472 changed files with 1265727 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1,195 @@
# Copyright 2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from datetime import date
from odoo import api, fields, models
class Pricelist(models.Model):
_inherit = "product.pricelist"
parent_pricelist_ids = fields.Many2many(
"product.pricelist",
relation="product_pricelist_cache__parent_pricelist_ids_rel",
column1="pricelist_id",
column2="parent_pricelist_id",
compute="_compute_parent_pricelist_ids",
store=True,
)
is_pricelist_cache_computed = fields.Boolean()
is_pricelist_cache_available = fields.Boolean(
compute="_compute_is_pricelist_cache_available"
)
@api.depends(
"item_ids", "item_ids.applied_on", "item_ids.base", "item_ids.base_pricelist_id"
)
def _compute_parent_pricelist_ids(self):
for record in self:
record.parent_pricelist_ids = record._get_parent_pricelists()
def _compute_is_pricelist_cache_available(self):
for record in self:
parents = record._get_parent_list_tree()
record.is_pricelist_cache_available = all(
parents.mapped("is_pricelist_cache_computed")
)
def _get_parent_list_tree(self):
self.ensure_one()
query = """
WITH RECURSIVE parent_pricelist AS (
SELECT id
FROM product_pricelist
WHERE id = %(pricelist_id)s
UNION SELECT item.base_pricelist_id AS id
FROM product_pricelist_item item
INNER JOIN parent_pricelist parent
ON item.pricelist_id = parent.id
)
SELECT id FROM parent_pricelist;
"""
self.env.flush_all()
self.env.cr.execute(query, {"pricelist_id": self.id})
return self.search([("id", "in", [row[0] for row in self.env.cr.fetchall()])])
@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)])
results = self._compute_price_rule(products, 1, date=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'
)
)
AND active = TRUE;
"""
self.env.flush_all()
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
)
)
)
AND active = TRUE;
"""
self.env.flush_all()
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()
query = """
SELECT base_pricelist_id
FROM product_pricelist_item
WHERE applied_on = '3_global'
AND base = 'pricelist'
AND base_pricelist_id IS NOT NULL
AND pricelist_id = %(pricelist_id)s
"""
self.env.cr.execute(query, {"pricelist_id": self.id})
return self.browse([row[0] for row in self.env.cr.fetchall()])
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)

View File

@@ -0,0 +1,268 @@
# Copyright 2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from psycopg2 import sql
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_reset_pricelist_cache" 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 = [
sql.SQL(", ").join(
map(sql.Literal, (record.id, product_prices[record.product_id.id]))
)
for record in self
]
query = sql.SQL(
"""
UPDATE
product_pricelist_cache AS pricelist_cache
SET
price = c.price
FROM (VALUES ({}))
AS c(id, price)
WHERE
c.id = pricelist_cache.id;
"""
).format(sql.SQL("), (").join(values))
self.env.flush_all()
self.env.cr.execute(query)
self.invalidate_model(["price"])
self.env.flush_all()
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 = [
sql.SQL(", ").join(
map(sql.Literal, (p_id, pricelist_id, product_prices[p_id]))
)
for p_id in product_ids
]
if values:
# create_everything from a single transaction
query = sql.SQL(
"""
INSERT INTO product_pricelist_cache (product_id, pricelist_id, price)
VALUES ({});
"""
).format(sql.SQL("), (").join(values))
self.env.flush_all()
self.env.cr.execute(query)
def _update_pricelist_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_pricelist_cache(pricelist.id, product_prices)
# Once this is done, set pricelist cache as computed on pricelist
pricelist.is_pricelist_cache_computed = True
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_model = self.env["product.pricelist"]
# Here, we split price computation in 2.
# Huge pricelists (root pricelists and factor pricelists) are computed
# on their own, in order to avoid long sql transactions.pricelist_model
# Smaller pricelists can be computed 3 by 3, since they are taking
# less time to process.
global_list_ids = pricelist_model._get_global_pricelist_ids()
# Belt and braces. Just to ensure higher level lists are executed first
global_list_ids.sort()
for list_id in global_list_ids:
self.with_delay().update_product_pricelist_cache(pricelist_ids=[list_id])
regular_list_ids = self.env["product.pricelist"].search(
[("id", "not in", global_list_ids)]
)
pricelist_ids = regular_list_ids.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 flush_pricelist_cache(self):
# 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)
self.env["product.pricelist"].search([]).is_pricelist_cache_computed = False
def cron_reset_pricelist_cache(self):
"""Recreates the whole price list cache."""
self.flush_pricelist_cache()
# 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):
xmlid = "pricelist_cache.product_pricelist_cache_action"
action = self.env["ir.actions.act_window"]._for_xml_id(xmlid)
if domain is not None:
action["domain"] = domain
return action

View File

@@ -0,0 +1,69 @@
# Copyright 2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from collections import defaultdict
from odoo import api, fields, models
class PricelistItem(models.Model):
_inherit = "product.pricelist.item"
base_pricelist_id = fields.Many2one(index=True)
product_tmpl_id = fields.Many2one(index=True)
product_id = fields.Many2one(index=True)
date_start = fields.Datetime(index=True)
date_end = fields.Datetime(index=True)
applied_on = fields.Selection(index=True)
categ_id = fields.Many2one(index=True)
min_quantity = fields.Float(index=True)
company_id = fields.Many2one(index=True)
pricelist_cache_update_skipped = fields.Boolean()
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.update_product_pricelist_cache()
return res
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]
)

View File

@@ -0,0 +1,23 @@
# 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"
@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

View File

@@ -0,0 +1,58 @@
# Copyright 2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import exceptions, fields, models
class Partner(models.Model):
_inherit = "res.partner"
is_pricelist_cache_available = fields.Boolean(
related="property_product_pricelist.is_pricelist_cache_available"
)
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):
if not self.is_pricelist_cache_available:
raise exceptions.UserError(
self.env._("Pricelist caching in progress. Retry later")
)
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)