From af1d46be569d1e97bce16d7aec139d28760de4ec Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Fri, 13 Jan 2023 10:18:08 +0100 Subject: [PATCH 01/15] [16.0][ADD] stock_average_daily_sale --- .flake8 | 1 + .../odoo/addons/stock_average_daily_sale | 1 + setup/stock_average_daily_sale/setup.py | 6 + stock_average_daily_sale/README.rst | 123 +++++ stock_average_daily_sale/__init__.py | 1 + stock_average_daily_sale/__manifest__.py | 30 ++ stock_average_daily_sale/data/ir_cron.xml | 16 + .../demo/stock_average_daily_sale_config.xml | 38 ++ stock_average_daily_sale/models/__init__.py | 3 + .../models/stock_average_daily_sale.py | 330 ++++++++++++ .../models/stock_average_daily_sale_config.py | 50 ++ .../models/stock_warehouse.py | 29 ++ stock_average_daily_sale/readme/CONFIGURE.rst | 21 + .../readme/CONTRIBUTORS.rst | 2 + .../readme/DESCRIPTION.rst | 18 + stock_average_daily_sale/readme/HISTORY.rst | 4 + stock_average_daily_sale/readme/ROADMAP.rst | 2 + .../security/stock_average_daily_sale.xml | 14 + .../stock_average_daily_sale_config.xml | 23 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 481 ++++++++++++++++++ stock_average_daily_sale/tests/__init__.py | 1 + stock_average_daily_sale/tests/common.py | 104 ++++ .../tests/test_average_daily_sale.py | 182 +++++++ .../views/stock_average_daily_sale.xml | 85 ++++ .../views/stock_average_daily_sale_config.xml | 38 ++ .../views/stock_warehouse.xml | 18 + 27 files changed, 1621 insertions(+) create mode 120000 setup/stock_average_daily_sale/odoo/addons/stock_average_daily_sale create mode 100644 setup/stock_average_daily_sale/setup.py create mode 100644 stock_average_daily_sale/README.rst create mode 100644 stock_average_daily_sale/__init__.py create mode 100644 stock_average_daily_sale/__manifest__.py create mode 100644 stock_average_daily_sale/data/ir_cron.xml create mode 100644 stock_average_daily_sale/demo/stock_average_daily_sale_config.xml create mode 100644 stock_average_daily_sale/models/__init__.py create mode 100644 stock_average_daily_sale/models/stock_average_daily_sale.py create mode 100644 stock_average_daily_sale/models/stock_average_daily_sale_config.py create mode 100644 stock_average_daily_sale/models/stock_warehouse.py create mode 100644 stock_average_daily_sale/readme/CONFIGURE.rst create mode 100644 stock_average_daily_sale/readme/CONTRIBUTORS.rst create mode 100644 stock_average_daily_sale/readme/DESCRIPTION.rst create mode 100644 stock_average_daily_sale/readme/HISTORY.rst create mode 100644 stock_average_daily_sale/readme/ROADMAP.rst create mode 100644 stock_average_daily_sale/security/stock_average_daily_sale.xml create mode 100644 stock_average_daily_sale/security/stock_average_daily_sale_config.xml create mode 100644 stock_average_daily_sale/static/description/icon.png create mode 100644 stock_average_daily_sale/static/description/index.html create mode 100644 stock_average_daily_sale/tests/__init__.py create mode 100644 stock_average_daily_sale/tests/common.py create mode 100644 stock_average_daily_sale/tests/test_average_daily_sale.py create mode 100644 stock_average_daily_sale/views/stock_average_daily_sale.xml create mode 100644 stock_average_daily_sale/views/stock_average_daily_sale_config.xml create mode 100644 stock_average_daily_sale/views/stock_warehouse.xml diff --git a/.flake8 b/.flake8 index e397e8ed4..1bad41fcb 100644 --- a/.flake8 +++ b/.flake8 @@ -10,3 +10,4 @@ select = C,E,F,W,B,B9 ignore = E203,E501,W503 per-file-ignores= __init__.py:F401 + stock_average_daily_sale/models/stock_average_daily_sale.py:B950 diff --git a/setup/stock_average_daily_sale/odoo/addons/stock_average_daily_sale b/setup/stock_average_daily_sale/odoo/addons/stock_average_daily_sale new file mode 120000 index 000000000..48276dfeb --- /dev/null +++ b/setup/stock_average_daily_sale/odoo/addons/stock_average_daily_sale @@ -0,0 +1 @@ +../../../../stock_average_daily_sale \ No newline at end of file diff --git a/setup/stock_average_daily_sale/setup.py b/setup/stock_average_daily_sale/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_average_daily_sale/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_average_daily_sale/README.rst b/stock_average_daily_sale/README.rst new file mode 100644 index 000000000..c51ffddbc --- /dev/null +++ b/stock_average_daily_sale/README.rst @@ -0,0 +1,123 @@ +======================== +Stock Average Daily Sale +======================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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%2Fstock--logistics--reporting-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-reporting/tree/16.0/stock_average_daily_sale + :alt: OCA/stock-logistics-reporting +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-reporting-16-0/stock-logistics-reporting-16-0-stock_average_daily_sale + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/151/16.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to gather stock consumptions and build reporting for average daily +sales (aka stock consumptions). Technically, this has been done through a +materialized postgresql view in order to be as fast as possible (some other flow +modules can depend on this). + +You can add several configurations depending on the window you want to analyze. +So, you can define criteria to filter data: + +* The Warehouse +* The product ABC classification +* The location kind (Zone, Area, Bin) +* The amount of time to look backward (in days or weeks or months or years) + +Moreover, you can define: + +* A safety factor +* A standard deviation exclusion factor + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +#. To configure data analysis, you should go to Inventory > Configuration > Average daily sales computation parameters + +#. You need to fill in the following informations: + + * The product ABC classification you want - see product_abc_classification module + * The concerned Warehouse + * The stock location kind (Zone, Area, Bin) - see stock_location_zone module + * The period of time to analyze back (in days/weeks/months/years) + * A standard deviation exclusion factor + * A safety factor + +#. Go to Configuration > Technical > Scheduled Actions > Refresh average daily sales materialized view + + By default, the sceduled action is set to refresh data each 4 hours. You can change + that depending on your needs. + +Known issues / Roadmap +====================== + +* Move the filter on saturday/sunday to configuration parameters +* An extensible data gathering query + +Changelog +========= + +16.0.1.0.0 (2023-01-13) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [16.0][ADD] stock_average_daily_sale + +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 +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Denis Roussel + +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/stock-logistics-reporting `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_average_daily_sale/__init__.py b/stock_average_daily_sale/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_average_daily_sale/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_average_daily_sale/__manifest__.py b/stock_average_daily_sale/__manifest__.py new file mode 100644 index 000000000..8194f546d --- /dev/null +++ b/stock_average_daily_sale/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Average Daily Sale", + "summary": """ + Allows to gather delivered products average on daily basis""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-reporting", + "depends": [ + "sale", + "stock_storage_type_putaway_abc", + "product_abc_classification", + "product_route_mto", + "stock_location_zone", + ], + "data": [ + "security/stock_average_daily_sale_config.xml", + "security/stock_average_daily_sale.xml", + "views/stock_average_daily_sale_config.xml", + "views/stock_average_daily_sale.xml", + "views/stock_warehouse.xml", + "data/ir_cron.xml", + ], + "demo": [ + "demo/stock_average_daily_sale_config.xml", + ], +} diff --git a/stock_average_daily_sale/data/ir_cron.xml b/stock_average_daily_sale/data/ir_cron.xml new file mode 100644 index 000000000..de4c6703f --- /dev/null +++ b/stock_average_daily_sale/data/ir_cron.xml @@ -0,0 +1,16 @@ + + + + Refresh average daily sales materialized view + + + 4 + hours + -1 + + + model.refresh_view() + code + + + diff --git a/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml new file mode 100644 index 000000000..4a8e83648 --- /dev/null +++ b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml @@ -0,0 +1,38 @@ + + + + + a + 2 + week + 3 + 0.3 + 2 + + + b + 13 + week + 3 + 0.3 + 2 + + + c + 26 + week + 3 + 0.3 + 2 + + diff --git a/stock_average_daily_sale/models/__init__.py b/stock_average_daily_sale/models/__init__.py new file mode 100644 index 000000000..f6a34f5ed --- /dev/null +++ b/stock_average_daily_sale/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_warehouse # isort:skip +from . import stock_average_daily_sale_config # isort:skip +from . import stock_average_daily_sale # isort:skip diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py new file mode 100644 index 000000000..5eb4d9c6a --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -0,0 +1,330 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from psycopg2.errors import ObjectNotInPrerequisiteState +from psycopg2.extensions import AsIs + +from odoo import _, api, fields, models + +from odoo.addons.stock_storage_type_putaway_abc.models.stock_location import ( + ABC_SELECTION, +) + +_logger = logging.getLogger(__name__) + + +class StockAverageDailySale(models.Model): + + _name = "stock.average.daily.sale" + _auto = False + _order = "abc_classification_level ASC, product_id ASC" + _description = "Average Daily Sale for Products" + + abc_classification_level = fields.Selection( + selection=ABC_SELECTION, required=True, readonly=True, index=True + ) + average_daily_sales_count = fields.Float( + required=True, digits="Product Unit of Measure" + ) + average_qty_by_sale = fields.Float( + required=True, digits="Product Unit of Measure", help="Average Daily Sales Qty" + ) + average_daily_qty = fields.Float( + digits="Product Unit of Measure", + required=True, + help="The average daily qty sold", + ) + config_id = fields.Many2one( + string="Stock Average Daily Sale Configuration", + comodel_name="stock.average.daily.sale.config", + required=True, + ) + date_from = fields.Date(string="From", required=True) + date_to = fields.Date(string="To", required=True) + is_mto = fields.Boolean( + string="On Order", + readonly=True, + store=True, + index=True, + ) + nbr_sales = fields.Integer(string="Number of Sales", required=True) + product_id = fields.Many2one( + comodel_name="product.product", string="Product", required=True, index=True + ) + safety = fields.Float( + required=True, + help="daily standard deviation * safety factor * sqrt(nbr days into period " + "without saturday and sunday", + ) + safety_bin_min_qty = fields.Float( + required=True, + digits="Product Unit of Measure", + help="Minimal safety qty into a bin location computed as: " + "average daily qty * number days in stock * safety", + ) + safety_bin_min_qty_old = fields.Float( + required=True, + digits="Product Unit of Measure", + help="Minimal value for the safety qty. Computed as: " + "number days in stock * GREATEST(average daily sales count, 1) * " + "(average qty by sale + (stddev * safety factor))", + ) + sale_ok = fields.Boolean( + string="Can be Sold", + readonly=True, + index=True, + help="Specify if the product can be selected in a sales order line.", + ) + standard_deviation = fields.Float(string="Qty Standard Deviation", required=True) + daily_standard_deviation = fields.Float( + string="Daily Qty Standard Deviation", required=True + ) + warehouse_id = fields.Many2one(comodel_name="stock.warehouse", required=True) + zone_location_id = fields.Many2one( + string="Location Zone", comodel_name="stock.location", index=True + ) + qty_in_stock = fields.Float( + string="Quantity in stock", + digits="Product Unit of Measure", + help="All stock locations, reserved product included", + required=True, + ) + + @api.model + def _check_view(self): + try: + self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) + return True + except ObjectNotInPrerequisiteState: + _logger.warning( + _("The materialized view has not been populated. Launch the cron.") + ) + return False + except Exception as e: + raise e + + # pylint: disable=redefined-outer-name + @api.model + def search_read( + self, domain=None, fields=None, offset=0, limit=None, order=None, **read_kwargs + ): + if not self._check_view(): + return self.browse() + return super().search_read( + domain=domain, + fields=fields, + offset=offset, + limit=limit, + order=order, + **read_kwargs + ) + + @api.model + def get_refresh_date(self): + return self.env["ir.config_parameter"].get_param( + "stock_average_daily_sale_refresh_date" + ) + + @api.model + def set_refresh_date(self, date=None): + if date is None: + date = fields.Datetime.now() + self.env["ir.config_parameter"].set_param( + "stock_average_daily_sale_refresh_date", date + ) + + @api.model + def refresh_view(self): + self.env.cr.execute("refresh materialized view %s", (AsIs(self._table),)) + self.set_refresh_date() + + def _create_materialized_view(self): + self.env.cr.execute( + "DROP MATERIALIZED VIEW IF EXISTS %s CASCADE", (AsIs(self._table),) + ) + self.env.cr.execute( + """ + CREATE MATERIALIZED VIEW %(table)s AS ( + -- Create a consolidated definition of parameters used into the average daily + -- sales computation. Parameters are specified by product ABC class + WITH cfg AS ( + SELECT + *, + -- end of the analyzed period + NOW()::date - '1 day'::interval as date_to, + -- start of the analyzed period computed from the original cfg + (NOW() - (period_value::TEXT || ' ' || period_name::TEXT)::INTERVAL):: date as date_from, + -- the number of business days between start and end computed by + -- removing saturday and sunday + (SELECT count(1) from (select EXTRACT(DOW FROM s.d::date) as dd + FROM generate_series( + (NOW() - (period_value::TEXT || ' ' || period_name::TEXT)::INTERVAL):: date , + (NOW()- '1 day'::interval)::date, + '1 day') AS s(d)) t + WHERE dd not in(0,6)) AS nrb_days_without_sat_sun + FROM + stock_average_daily_sale_config + ), + -- Create a consolidated view of all the stock moves from internal locations + -- to customer location. The consolidation is done by including all the moves + -- with a date done into the period provided by the configuration for each + -- product according to its abc classification. + -- The consolidated view also include the standard deviation of the product qty + -- sold at once, and the lower and upper bounds to use to exclude qties + -- that diverge too much from the average qty by product. The factor applied + -- to the standard deviation to compute the lower and upper bounds is also + -- provided by the configuration according the product's abc classification + -- All the products without abc classification are linked to the 'C' class + deliveries_last AS ( + SELECT + sm.product_id, + sm.product_uom_qty, + sl_src.warehouse_id, + sl_src.zone_location_id, + (avg(product_uom_qty) OVER pid + - (stddev_samp(product_uom_qty) OVER pid * cfg.standard_deviation_exclude_factor) + ) as lower_bound, + (avg(product_uom_qty) OVER pid + + ( stddev_samp(product_uom_qty) OVER pid * cfg.standard_deviation_exclude_factor) + ) as upper_bound, + coalesce ((stddev_samp(product_uom_qty) OVER pid), 0) as standard_deviation, + cfg.nrb_days_without_sat_sun, + cfg.date_from, + cfg.date_to, + cfg.id as config_id, + sm.date + FROM stock_move sm + JOIN stock_location sl_src ON sm.location_id = sl_src.id + JOIN stock_location sl_dest ON sm.location_dest_id = sl_dest.id + JOIN product_product pp on pp.id = sm.product_id + JOIN product_template pt on pp.product_tmpl_id = pt.id + JOIN cfg on cfg.abc_classification_level = coalesce(pt.abc_storage, 'c') + WHERE + sl_src.usage in ('view', 'internal') + AND sl_dest.usage = 'customer' + AND sm.priority > '0' + AND sm.date BETWEEN cfg.date_from AND cfg.date_to + AND sm.state = 'done' + WINDOW pid AS (PARTITION BY sm.product_id, sm.warehouse_id) + ), + + averages AS( + SELECT + concat(warehouse_id, product_id)::integer as id, + product_id, + warehouse_id, + zone_location_id, + (avg(product_uom_qty) FILTER + (WHERE product_uom_qty BETWEEN lower_bound AND upper_bound OR standard_deviation = 0) + )::numeric AS average_qty_by_sale, + (count(product_uom_qty) FILTER + (WHERE product_uom_qty BETWEEN lower_bound AND upper_bound OR standard_deviation = 0) + / nrb_days_without_sat_sun::numeric) AS average_daily_sales_count, + count(product_uom_qty) FILTER + (WHERE product_uom_qty BETWEEN lower_bound AND upper_bound OR standard_deviation = 0)::double precision as nbr_sales, + standard_deviation::numeric , + date_from, + date_to, + config_id, + nrb_days_without_sat_sun + FROM deliveries_last + GROUP BY product_id, warehouse_id, zone_location_id, standard_deviation, nrb_days_without_sat_sun, date_from, date_to, config_id + ), + -- Compute the stock by product in locations under stock + stock_qty AS ( + SELECT sq.product_id AS pp_id, + sum(sq.quantity) AS qty_in_stock, + sl.warehouse_id AS warehouse_id + FROM stock_quant sq + JOIN stock_location sl ON sq.location_id = sl.id + JOIN stock_warehouse sw ON sl.warehouse_id = sw.id + WHERE sl.parent_path LIKE concat('%%/', sw.average_daily_sale_root_location_id, '/%%') + GROUP BY sq.product_id, sl.warehouse_id + ), + -- Compute the standard deviation of the average daily sales count + -- excluding saturday and sunday + daily_standard_deviation AS( + SELECT + id, + product_id, + warehouse_id, + stddev_samp(daily_sales) as daily_standard_deviation + from ( + SELECT + to_char(date_trunc('day', date), 'YYYY-MM-DD'), + concat(warehouse_id, product_id)::integer as id, + product_id, + warehouse_id, + (count(product_uom_qty) FILTER + (WHERE product_uom_qty BETWEEN lower_bound AND upper_bound OR standard_deviation = 0) + ) as daily_sales + FROM deliveries_last + WHERE EXTRACT(DOW FROM date) <> '0' AND EXTRACT(DOW FROM date) <> '6' + GROUP BY product_id, warehouse_id, 1 + ) as averages_daily group by id, product_id, warehouse_id + + ) + + -- Collect the data for the materialized view + SELECT + t.id, + t.product_id, + t.warehouse_id, + t.zone_location_id, + average_qty_by_sale, + average_daily_sales_count, + average_qty_by_sale * average_daily_sales_count as average_daily_qty, + nbr_sales, + standard_deviation, + date_from, + date_to, + config_id, + abc_classification_level, + sale_ok, + is_mto, + sqty.qty_in_stock as qty_in_stock, + ds.daily_standard_deviation, + ds.daily_standard_deviation * cfg.safety_factor * sqrt(nrb_days_without_sat_sun) as safety, + (cfg.number_days_qty_in_stock * average_qty_by_sale * average_daily_sales_count) + (ds.daily_standard_deviation * cfg.safety_factor * sqrt(nrb_days_without_sat_sun)) as safety_bin_min_qty_new, + cfg.number_days_qty_in_stock * GREATEST(average_daily_sales_count, 1) * (average_qty_by_sale + (standard_deviation * cfg.safety_factor)) as safety_bin_min_qty_old, + GREATEST( + (cfg.number_days_qty_in_stock * average_qty_by_sale * average_daily_sales_count) + (ds.daily_standard_deviation * cfg.safety_factor * sqrt(nrb_days_without_sat_sun)), + (cfg.number_days_qty_in_stock * average_qty_by_sale) + ) as safety_bin_min_qty + FROM averages t + JOIN daily_standard_deviation ds on ds.id= t.id + JOIN stock_average_daily_sale_config cfg on cfg.id = t.config_id + JOIN stock_qty sqty on sqty.pp_id = t.product_id AND t.warehouse_id = sqty.warehouse_id + JOIN product_product pp on pp.id = t.product_id + JOIN product_template pt on pt.id = pp.product_tmpl_id + ORDER BY product_id + ) WITH NO DATA;""", + { + "table": AsIs(self._table), + }, + ) + self.env.cr.execute( + "CREATE UNIQUE INDEX pk_%s ON %s (id)", + (AsIs(self._table), AsIs(self._table)), + ) + for name, field in self._fields.items(): + if not field.index: + continue + self.env.cr.execute( + "CREATE INDEX %s_%s_idx ON %s (%s)", + (AsIs(self._table), AsIs(name), AsIs(self._table), AsIs(name)), + ) + self.set_refresh_date(date=False) + cron = self.env.ref( + "stock_average_daily_sale.refresh_materialized_view", + # at install, won't exist yet + raise_if_not_found=False, + ) + # refresh data asap, but not during the upgrade + if cron: + cron.nextcall = fields.Datetime.now() + + def init(self): + self._create_materialized_view() diff --git a/stock_average_daily_sale/models/stock_average_daily_sale_config.py b/stock_average_daily_sale/models/stock_average_daily_sale_config.py new file mode 100644 index 000000000..95496a691 --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale_config.py @@ -0,0 +1,50 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from odoo.addons.stock_storage_type_putaway_abc.models.stock_location import ( + ABC_SELECTION, +) + + +class StockAverageDailySaleConfig(models.Model): + + _name = "stock.average.daily.sale.config" + _description = "Average daily sales computation parameters" + + abc_classification_level = fields.Selection( + selection=ABC_SELECTION, required=True, readonly=True + ) + standard_deviation_exclude_factor = fields.Float(required=True, digits=(2, 2)) + warehouse_id = fields.Many2one( + string="Warehouse", + comodel_name="stock.warehouse", + required=True, + ondelete="cascade", + default=lambda self: self.env["stock.warehouse"].search( + [("company_id", "=", self.env.company.id)], limit=1 + ), + readonly=True, + ) + stock_location_kind = fields.Selection( + selection=lambda self: self.env["stock.location"] + ._fields["location_kind"] + .selection, + default="zone", + ) + period_name = fields.Selection( + string="Period analyzed unit", + selection=[ + ("year", "Years"), + ("month", "Months"), + ("week", "Weeks"), + ("day", "Days"), + ], + required=True, + ) + period_value = fields.Integer("Period analyzed value", required=True) + number_days_qty_in_stock = fields.Integer( + string="Number of days of quantities in stock", required=True, default=2 + ) + safety_factor = fields.Float(digits=(2, 2), required=True) diff --git a/stock_average_daily_sale/models/stock_warehouse.py b/stock_average_daily_sale/models/stock_warehouse.py new file mode 100644 index 000000000..2d34710fa --- /dev/null +++ b/stock_average_daily_sale/models/stock_warehouse.py @@ -0,0 +1,29 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class StockWarehouse(models.Model): + + _inherit = "stock.warehouse" + + average_daily_sale_root_location_id = fields.Many2one( + comodel_name="stock.location", + string="Average Daily Sale Root Location", + compute="_compute_average_daily_sale_root_location_id", + store=True, + readonly=False, + required=True, + precompute=True, + help="This is the root location for daily sale average stock computations", + ) + + @api.depends("lot_stock_id") + def _compute_average_daily_sale_root_location_id(self): + """ + Set a default root location from warehouse lot stock + """ + for warehouse in self.filtered( + lambda w: not w.average_daily_sale_root_location_id + ): + warehouse.average_daily_sale_root_location_id = warehouse.lot_stock_id diff --git a/stock_average_daily_sale/readme/CONFIGURE.rst b/stock_average_daily_sale/readme/CONFIGURE.rst new file mode 100644 index 000000000..e7838a5a2 --- /dev/null +++ b/stock_average_daily_sale/readme/CONFIGURE.rst @@ -0,0 +1,21 @@ +* To configure data analysis, you should go to Inventory > Configuration > Average daily sales computation parameters + +* You need to fill in the following informations: + + * The product ABC classification you want - see product_abc_classification module + * The concerned Warehouse + * The stock location kind (Zone, Area, Bin) - see stock_location_zone module + * The period of time to analyze back (in days/weeks/months/years) + * A standard deviation exclusion factor + * A safety factor + +* Go to Configuration > Technical > Scheduled Actions > Refresh average daily sales materialized view + + By default, the scheduled action is set to refresh data each 4 hours. You can change + that depending on your needs. + +* By default, the root location where analysis is done is the Warehouse stock location, + but you can change it. + + * Go to Inventory > Configuration > Warehouses + * Change the 'Average Daily Sale Root Location' field according your needs diff --git a/stock_average_daily_sale/readme/CONTRIBUTORS.rst b/stock_average_daily_sale/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..7c2997e3f --- /dev/null +++ b/stock_average_daily_sale/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon +* Denis Roussel diff --git a/stock_average_daily_sale/readme/DESCRIPTION.rst b/stock_average_daily_sale/readme/DESCRIPTION.rst new file mode 100644 index 000000000..a5b0d1da6 --- /dev/null +++ b/stock_average_daily_sale/readme/DESCRIPTION.rst @@ -0,0 +1,18 @@ +This module allows to gather stock consumptions and build reporting for average daily +sales (aka stock consumptions). Technically, this has been done through a +materialized postgresql view in order to be as fast as possible (some other flow +modules can depend on this). + +You can add several configurations depending on the window you want to analyze. +So, you can define criteria to filter data: + +* The Warehouse +* The product ABC classification +* The location kind (Zone, Area, Bin) +* The amount of time to look backward (in days or weeks or months or years) + +Moreover, you can define: + +* A safety factor +* A standard deviation exclusion factor +* A different root location for analysis per Warehouse diff --git a/stock_average_daily_sale/readme/HISTORY.rst b/stock_average_daily_sale/readme/HISTORY.rst new file mode 100644 index 000000000..f65b11f38 --- /dev/null +++ b/stock_average_daily_sale/readme/HISTORY.rst @@ -0,0 +1,4 @@ +16.0.1.0.0 (2023-01-13) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [16.0][ADD] stock_average_daily_sale diff --git a/stock_average_daily_sale/readme/ROADMAP.rst b/stock_average_daily_sale/readme/ROADMAP.rst new file mode 100644 index 000000000..9dc38ed17 --- /dev/null +++ b/stock_average_daily_sale/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Move the filter on saturday/sunday to configuration parameters +* An extensible data gathering query diff --git a/stock_average_daily_sale/security/stock_average_daily_sale.xml b/stock_average_daily_sale/security/stock_average_daily_sale.xml new file mode 100644 index 000000000..2fad7c29e --- /dev/null +++ b/stock_average_daily_sale/security/stock_average_daily_sale.xml @@ -0,0 +1,14 @@ + + + + + stock.average.daily.sale access user + + + + + + + + diff --git a/stock_average_daily_sale/security/stock_average_daily_sale_config.xml b/stock_average_daily_sale/security/stock_average_daily_sale_config.xml new file mode 100644 index 000000000..c9b326ab8 --- /dev/null +++ b/stock_average_daily_sale/security/stock_average_daily_sale_config.xml @@ -0,0 +1,23 @@ + + + + + stock_average_daily_sale_config access user + + + + + + + + + stock_average_daily_sale_config access manager + + + + + + + + diff --git a/stock_average_daily_sale/static/description/icon.png b/stock_average_daily_sale/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/stock_average_daily_sale/static/description/index.html b/stock_average_daily_sale/static/description/index.html new file mode 100644 index 000000000..e5a4f1821 --- /dev/null +++ b/stock_average_daily_sale/static/description/index.html @@ -0,0 +1,481 @@ + + + + + + +Stock Average Daily Sale + + + +
+

Stock Average Daily Sale

+ + +

Beta License: AGPL-3 OCA/stock-logistics-reporting Translate me on Weblate Try me on Runbot

+

This module allows to gather stock consumptions and build reporting for average daily +sales (aka stock consumptions). Technically, this has been done through a +materialized postgresql view in order to be as fast as possible (some other flow +modules can depend on this).

+

You can add several configurations depending on the window you want to analyze. +So, you can define criteria to filter data:

+
    +
  • The Warehouse
  • +
  • The product ABC classification
  • +
  • The location kind (Zone, Area, Bin)
  • +
  • The amount of time to look backward (in days or weeks or months or years)
  • +
+

Moreover, you can define:

+
    +
  • A safety factor
  • +
  • A standard deviation exclusion factor
  • +
+

Table of contents

+ +
+

Configuration

+
    +
  1. To configure data analysis, you should go to Inventory > Configuration > Average daily sales computation parameters
  2. +
  3. You need to fill in the following informations:
  4. +
+
+
    +
  • The product ABC classification you want - see product_abc_classification module
  • +
  • The concerned Warehouse
  • +
  • The stock location kind (Zone, Area, Bin) - see stock_location_zone module
  • +
  • The period of time to analyze back (in days/weeks/months/years)
  • +
  • A standard deviation exclusion factor
  • +
  • A safety factor
  • +
+
+
    +
  1. Go to Configuration > Technical > Scheduled Actions > Refresh average daily sales materialized view
  2. +
+
+By default, the sceduled action is set to refresh data each 4 hours. You can change +that depending on your needs.
+
+
+

Known issues / Roadmap

+
    +
  • Move the filter on saturday/sunday to configuration parameters
  • +
  • An extensible data gathering query
  • +
+
+
+

Changelog

+
+

16.0.1.0.0 (2023-01-13)

+
    +
  • [16.0][ADD] stock_average_daily_sale
  • +
+
+
+
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

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/stock-logistics-reporting project on GitHub.

+

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

+
+
+
+ + diff --git a/stock_average_daily_sale/tests/__init__.py b/stock_average_daily_sale/tests/__init__.py new file mode 100644 index 000000000..822be552e --- /dev/null +++ b/stock_average_daily_sale/tests/__init__.py @@ -0,0 +1 @@ +from . import test_average_daily_sale diff --git a/stock_average_daily_sale/tests/common.py b/stock_average_daily_sale/tests/common.py new file mode 100644 index 000000000..05d468561 --- /dev/null +++ b/stock_average_daily_sale/tests/common.py @@ -0,0 +1,104 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +class CommonAverageSaleTest: + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.inventory_obj = cls.env["stock.quant"].with_context(inventory_mode=True) + cls.customers = cls.env.ref("stock.stock_location_customers") + cls.location_obj = cls.env["stock.location"] + cls.move_obj = cls.env["stock.move"] + cls.warehouse_0 = cls.env.ref("stock.warehouse0") + cls.average_sale_obj = cls.env["stock.average.daily.sale"] + cls.average_sale_obj._create_materialized_view() + cls.view_cron = cls.env.ref( + "stock_average_daily_sale.refresh_materialized_view" + ) + # Create the following structure: + # [Stock] + # (...) + # # [Zone Location] + # # # [Area Location] + # # # # [Bin Location] + cls.location_zone = cls.location_obj.create( + { + "name": "Zone Location", + "is_zone": True, + "location_id": cls.warehouse_0.lot_stock_id.id, + } + ) + cls.location_area = cls.location_obj.create( + {"name": "Area Location", "location_id": cls.location_zone.id} + ) + cls.location_bin = cls.location_obj.create( + {"name": "Bin Location", "location_id": cls.location_area.id} + ) + cls.location_bin_2 = cls.location_obj.create( + {"name": "Bin Location 2", "location_id": cls.location_area.id} + ) + cls.scrap_location = cls.location_obj.create( + { + "name": "Scrap Location", + "usage": "inventory", + } + ) + cls.stock_location = cls.env.ref("stock.warehouse0").lot_stock_id + + cls._create_products() + + @classmethod + def _create_inventory(cls): + cls.inventory_obj.create( + { + "product_id": cls.product_1.id, + "inventory_quantity": 50.0, + "location_id": cls.location_bin.id, + } + )._apply_inventory() + cls.inventory_obj.create( + { + "product_id": cls.product_2.id, + "inventory_quantity": 60.0, + "location_id": cls.location_bin_2.id, + } + )._apply_inventory() + + @classmethod + def _create_products(cls): + cls.product_1 = cls.env["product.product"].create( + { + "name": "Product 1", + "type": "product", + } + ) + cls.product_2 = cls.env["product.product"].create( + { + "name": "Product 2", + "type": "product", + } + ) + + @classmethod + def _create_move(cls, product, origin_location, qty): + move = cls.move_obj.create( + { + "product_id": product.id, + "name": product.name, + "location_id": origin_location.id, + "location_dest_id": cls.customers.id, + "product_uom_qty": qty, + "priority": "1", + } + ) + # TODO: Check why this is necessary - it's in materialzed view query + move.priority = "1" + return move + + @classmethod + def _refresh(cls): + # Flush to allow materialized view to be correctly populated + cls.env.flush_all() + cls.env["stock.average.daily.sale"].refresh_view() diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py new file mode 100644 index 000000000..905babb8b --- /dev/null +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -0,0 +1,182 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from dateutil.relativedelta import relativedelta +from freezegun import freeze_time + +from odoo.fields import Date +from odoo.tests.common import TransactionCase + +from .common import CommonAverageSaleTest + + +class TestAverageSale(CommonAverageSaleTest, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # As NOW() postgres function cannot easily mocked in python, + # We use now as basis for computations + cls.now = Date.today() + + cls.inventory_date = Date.to_string(cls.now - relativedelta(cls.now, weeks=30)) + + with freeze_time(cls.inventory_date): + cls._create_inventory() + + def test_average_sale(self): + # By default, products have abc_storage == 'b' + # So, the averages should correspond to 'b' one + move_1_date = Date.to_string(self.now - relativedelta(weeks=11)) + with freeze_time(move_1_date): + move = self._create_move(self.product_1, self.location_bin, 10.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + move_2_date = Date.to_string(self.now - relativedelta(weeks=9)) + with freeze_time(move_2_date): + move = self._create_move(self.product_2, self.location_bin_2, 12.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + self._refresh() + # self.env.flush_all() + avg_product_1 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_1.id)] + ) + + self.assertRecordValues( + avg_product_1, + [ + { + "nbr_sales": 1.0, + "average_qty_by_sale": 10.0, + "qty_in_stock": 40.0, + "zone_location_id": self.location_zone.id, + } + ], + ) + avg_product_2 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_2.id)] + ) + self.assertRecordValues( + avg_product_2, + [ + { + "nbr_sales": 1.0, + "average_qty_by_sale": 12.0, + "qty_in_stock": 48.0, + "zone_location_id": self.location_zone.id, + } + ], + ) + + def test_average_sale_multiple(self): + # By default, products have abc_storage == 'b' + # So, the averages should correspond to 'b' one + move_1_date = Date.to_string(self.now - relativedelta(weeks=11)) + with freeze_time(move_1_date): + move = self._create_move(self.product_1, self.location_bin, 10.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + move_1_date = Date.to_string(self.now - relativedelta(weeks=10)) + with freeze_time(move_1_date): + move = self._create_move(self.product_1, self.location_bin, 8.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + move_1_date = Date.to_string(self.now - relativedelta(weeks=9)) + with freeze_time(move_1_date): + move = self._create_move(self.product_1, self.location_bin, 13.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + move_2_date = Date.to_string(self.now - relativedelta(weeks=9)) + with freeze_time(move_2_date): + move = self._create_move(self.product_2, self.location_bin_2, 12.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + move_2_date = Date.to_string(self.now - relativedelta(weeks=8)) + with freeze_time(move_2_date): + move = self._create_move(self.product_2, self.location_bin_2, 4.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + self._refresh() + + avg_product_1 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_1.id)] + ) + + self.assertRecordValues( + avg_product_1, + [ + { + "nbr_sales": 3.0, + "qty_in_stock": 19.0, + "zone_location_id": self.location_zone.id, + } + ], + ) + self.assertAlmostEqual(10.33, avg_product_1.average_qty_by_sale, places=2) + + avg_product_2 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_2.id)] + ) + self.assertRecordValues( + avg_product_2, + [ + { + "nbr_sales": 2.0, + "average_qty_by_sale": 8.0, + "qty_in_stock": 44.0, + "zone_location_id": self.location_zone.id, + } + ], + ) + + def test_average_sale_profile_a(self): + # Test with profile 'a' + # Check that no average daily is found + self.product_1.abc_storage = "a" + self.product_2.abc_storage = "a" + move_1_date = Date.to_string(self.now - relativedelta(weeks=11)) + with freeze_time(move_1_date): + move = self._create_move(self.product_1, self.location_bin, 10.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + move_2_date = Date.to_string(self.now - relativedelta(weeks=9)) + with freeze_time(move_2_date): + move = self._create_move(self.product_2, self.location_bin_2, 12.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + self._refresh() + + avg_product_1 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_1.id)] + ) + + self.assertFalse(avg_product_1) + avg_product_2 = self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_2.id)] + ) + self.assertFalse(avg_product_2) diff --git a/stock_average_daily_sale/views/stock_average_daily_sale.xml b/stock_average_daily_sale/views/stock_average_daily_sale.xml new file mode 100644 index 000000000..a92bb2b6c --- /dev/null +++ b/stock_average_daily_sale/views/stock_average_daily_sale.xml @@ -0,0 +1,85 @@ + + + + + stock.daily.sale.search (in stock_average_daily_sale) + stock.average.daily.sale + + + + + + + + + + + + + + + + + stock.daily.sale.tree (in stock_average_daily_sale) + stock.average.daily.sale + + + + + + + + + + + + + + + + + + + + + + Average Daily Sales + stock.average.daily.sale + tree + [] + {"search_default_filter_to_sell":1, "search_default_normal_product": 1} + +

+ No data found. + + You maybe need to launch the cron to refresh the average daily sale data. +

+
+
+ + Average Daily Sales + + + + +
diff --git a/stock_average_daily_sale/views/stock_average_daily_sale_config.xml b/stock_average_daily_sale/views/stock_average_daily_sale_config.xml new file mode 100644 index 000000000..ccda26f94 --- /dev/null +++ b/stock_average_daily_sale/views/stock_average_daily_sale_config.xml @@ -0,0 +1,38 @@ + + + + + stock.average.daily.sale.config.tree (in stock_average_daily_sale) + stock.average.daily.sale.config + + + + + + + + + + + + + + Average daily sales computation parameters + stock.average.daily.sale.config + tree + [] + {} + + + Average daily sales computation parameters + + + + + diff --git a/stock_average_daily_sale/views/stock_warehouse.xml b/stock_average_daily_sale/views/stock_warehouse.xml new file mode 100644 index 000000000..9715f0123 --- /dev/null +++ b/stock_average_daily_sale/views/stock_warehouse.xml @@ -0,0 +1,18 @@ + + + + + + stock.warehouse + stock.warehouse + + + + + + + + + + From be8ce7fe43eac379ae224a85cee036826a2e64d0 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Tue, 14 Feb 2023 14:50:33 +0100 Subject: [PATCH 02/15] [IMP] stock_average_daily_sale: Store the configurations on profile level + add demo The configurations are now stored on abc profiles. Some demo data are loaded in order to show this module features more easily. --- requirements.txt | 2 + stock_average_daily_sale/__init__.py | 2 +- stock_average_daily_sale/__manifest__.py | 5 + .../demo/stock_average_daily_sale_config.xml | 12 +++ stock_average_daily_sale/demo/stock_move.xml | 7 ++ stock_average_daily_sale/models/__init__.py | 1 + .../models/abc_classification_profile.py | 15 +++ .../models/stock_average_daily_sale.py | 6 ++ .../models/stock_average_daily_sale_config.py | 5 + .../stock_average_daily_sale_demo.xml | 14 +++ .../views/abc_classification_profile.xml | 20 ++++ stock_average_daily_sale/wizards/__init__.py | 1 + .../wizards/stock_average_daily_sale_demo.py | 91 +++++++++++++++++++ 13 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 requirements.txt create mode 100644 stock_average_daily_sale/demo/stock_move.xml create mode 100644 stock_average_daily_sale/models/abc_classification_profile.py create mode 100644 stock_average_daily_sale/security/stock_average_daily_sale_demo.xml create mode 100644 stock_average_daily_sale/views/abc_classification_profile.xml create mode 100644 stock_average_daily_sale/wizards/__init__.py create mode 100644 stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..832da4f88 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +freezegun diff --git a/stock_average_daily_sale/__init__.py b/stock_average_daily_sale/__init__.py index 0650744f6..64face17a 100644 --- a/stock_average_daily_sale/__init__.py +++ b/stock_average_daily_sale/__init__.py @@ -1 +1 @@ -from . import models +from . import models, wizards diff --git a/stock_average_daily_sale/__manifest__.py b/stock_average_daily_sale/__manifest__.py index 8194f546d..582a9d34b 100644 --- a/stock_average_daily_sale/__manifest__.py +++ b/stock_average_daily_sale/__manifest__.py @@ -13,18 +13,23 @@ "sale", "stock_storage_type_putaway_abc", "product_abc_classification", + "product_abc_classification_sale_stock", "product_route_mto", "stock_location_zone", ], "data": [ "security/stock_average_daily_sale_config.xml", "security/stock_average_daily_sale.xml", + "security/stock_average_daily_sale_demo.xml", "views/stock_average_daily_sale_config.xml", "views/stock_average_daily_sale.xml", + "views/abc_classification_profile.xml", "views/stock_warehouse.xml", "data/ir_cron.xml", ], + "external_dependencies": {"python": ["freezegun"]}, "demo": [ "demo/stock_average_daily_sale_config.xml", + "demo/stock_move.xml", ], } diff --git a/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml index 4a8e83648..2b90e8449 100644 --- a/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml +++ b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml @@ -6,6 +6,10 @@ model="stock.average.daily.sale.config" id="stock_average_daily_sale_config_level_a" > + a 2 week @@ -17,6 +21,10 @@ model="stock.average.daily.sale.config" id="stock_average_daily_sale_config_level_b" > + b 13 week @@ -28,6 +36,10 @@ model="stock.average.daily.sale.config" id="stock_average_daily_sale_config_level_c" > + c 26 week diff --git a/stock_average_daily_sale/demo/stock_move.xml b/stock_average_daily_sale/demo/stock_move.xml new file mode 100644 index 000000000..20ba1092e --- /dev/null +++ b/stock_average_daily_sale/demo/stock_move.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/stock_average_daily_sale/models/__init__.py b/stock_average_daily_sale/models/__init__.py index f6a34f5ed..756ee7b45 100644 --- a/stock_average_daily_sale/models/__init__.py +++ b/stock_average_daily_sale/models/__init__.py @@ -1,3 +1,4 @@ from . import stock_warehouse # isort:skip from . import stock_average_daily_sale_config # isort:skip from . import stock_average_daily_sale # isort:skip +from . import abc_classification_profile diff --git a/stock_average_daily_sale/models/abc_classification_profile.py b/stock_average_daily_sale/models/abc_classification_profile.py new file mode 100644 index 000000000..e6275aba5 --- /dev/null +++ b/stock_average_daily_sale/models/abc_classification_profile.py @@ -0,0 +1,15 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AbcClassificationProfile(models.Model): + + _inherit = "abc.classification.profile" + + stock_average_daily_sale_config_ids = fields.One2many( + comodel_name="stock.average.daily.sale.config", + inverse_name="abc_classification_profile_id", + string="Average Daily Sale Configurations", + ) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 5eb4d9c6a..0dfb148cd 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -22,6 +22,11 @@ class StockAverageDailySale(models.Model): _order = "abc_classification_level ASC, product_id ASC" _description = "Average Daily Sale for Products" + abc_classification_profile_id = fields.Many2one( + comodel_name="abc.classification.profile", + required=True, + index=True, + ) abc_classification_level = fields.Selection( selection=ABC_SELECTION, required=True, readonly=True, index=True ) @@ -282,6 +287,7 @@ def _create_materialized_view(self): date_to, config_id, abc_classification_level, + cfg.abc_classification_profile_id, sale_ok, is_mto, sqty.qty_in_stock as qty_in_stock, diff --git a/stock_average_daily_sale/models/stock_average_daily_sale_config.py b/stock_average_daily_sale/models/stock_average_daily_sale_config.py index 95496a691..4b2521f5c 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale_config.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale_config.py @@ -13,6 +13,11 @@ class StockAverageDailySaleConfig(models.Model): _name = "stock.average.daily.sale.config" _description = "Average daily sales computation parameters" + abc_classification_profile_id = fields.Many2one( + comodel_name="abc.classification.profile", + required=True, + ondelete="cascade", + ) abc_classification_level = fields.Selection( selection=ABC_SELECTION, required=True, readonly=True ) diff --git a/stock_average_daily_sale/security/stock_average_daily_sale_demo.xml b/stock_average_daily_sale/security/stock_average_daily_sale_demo.xml new file mode 100644 index 000000000..44ad9254a --- /dev/null +++ b/stock_average_daily_sale/security/stock_average_daily_sale_demo.xml @@ -0,0 +1,14 @@ + + + + + stock.average.daily.sale.demo access user + + + + + + + + diff --git a/stock_average_daily_sale/views/abc_classification_profile.xml b/stock_average_daily_sale/views/abc_classification_profile.xml new file mode 100644 index 000000000..600e734d8 --- /dev/null +++ b/stock_average_daily_sale/views/abc_classification_profile.xml @@ -0,0 +1,20 @@ + + + + + abc.classification.profile.form (in stock_average_daily_sale) + abc.classification.profile + + + + + + + + diff --git a/stock_average_daily_sale/wizards/__init__.py b/stock_average_daily_sale/wizards/__init__.py new file mode 100644 index 000000000..d11cee86f --- /dev/null +++ b/stock_average_daily_sale/wizards/__init__.py @@ -0,0 +1 @@ +from . import stock_average_daily_sale_demo diff --git a/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py new file mode 100644 index 000000000..be60f4b7b --- /dev/null +++ b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py @@ -0,0 +1,91 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from dateutil.relativedelta import relativedelta +from freezegun import freeze_time + +from odoo import _, api, models +from odoo.fields import Date, Datetime + +_logger = logging.getLogger(__name__) + + +class StockAverageDailySaleDemo(models.TransientModel): + + _name = "stock.average.daily.sale.demo" + _description = "Wizard to populate demo data with past moves for Average Daily Sale" + + def _create_move(self, product, origin_location, qty): + suppliers = self.env.ref("stock.stock_location_suppliers") + customers = self.env.ref("stock.stock_location_customers") + move_obj = self.env["stock.move"] + # Create first an incoming move to avoid negative quantities + move = move_obj.create( + { + "product_id": product.id, + "name": product.name, + "location_id": suppliers.id, + "location_dest_id": customers.id, + "product_uom_qty": qty, + } + ) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + + # Create the OUT move + move = move_obj.create( + { + "product_id": product.id, + "name": product.name, + "location_id": origin_location.id, + "location_dest_id": customers.id, + "product_uom_qty": qty, + "priority": "1", + } + ) + return move + + @api.model + def _create_movement(self, product): + now = Datetime.now() + stock = self.env.ref("stock.stock_location_stock") + move_1_date = Date.to_string(now - relativedelta(weeks=11)) + with freeze_time(move_1_date): + move = self._create_move(product, stock, 10.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + move.priority = "1" + move_2_date = Date.to_string(now - relativedelta(weeks=9)) + with freeze_time(move_2_date): + move = self._create_move(product, stock, 12.0) + move._action_confirm() + move._action_assign() + move.quantity_done = move.product_uom_qty + move._action_done() + move.priority = "1" + + @api.model + def _action_create_data(self): + """ + This is called through an xml function in order to populate + demo data with past moves as the report depends on that. + """ + module = self.env["ir.module.module"].search( + [("name", "=", "stock_average_daily_sale"), ("demo", "=", True)] + ) + if not module: + _logger.warning( + _("You cannot call the _action_create_data() on production database.") + ) + return + product = self.env.ref("product.product_product_25") + self._create_movement(product) + product = self.env.ref("product.product_product_27") + self._create_movement(product) + + self.env["stock.average.daily.sale"].refresh_view() From 1221fa1d444691cc0fea0d67d84905ad754c5340 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 15 Feb 2023 13:36:29 +0100 Subject: [PATCH 03/15] [IMP] stock_average_daily_sale: Improve profile view --- .../views/abc_classification_profile.xml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/stock_average_daily_sale/views/abc_classification_profile.xml b/stock_average_daily_sale/views/abc_classification_profile.xml index 600e734d8..e958473a7 100644 --- a/stock_average_daily_sale/views/abc_classification_profile.xml +++ b/stock_average_daily_sale/views/abc_classification_profile.xml @@ -12,9 +12,11 @@ ref="product_abc_classification.abc_classification_profile_form_view" /> - - - + + + + + From 3348644c8456cab186ddefb7c7be83b19ba08a6d Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 13 Jul 2023 14:02:27 +0200 Subject: [PATCH 04/15] [IMP] Remove zone location dependency, rely on warehouse only --- stock_average_daily_sale/__manifest__.py | 1 - .../models/stock_average_daily_sale.py | 8 +------- .../models/stock_average_daily_sale_config.py | 6 ------ stock_average_daily_sale/tests/common.py | 1 - .../tests/test_average_daily_sale.py | 8 ++++---- .../views/stock_average_daily_sale.xml | 9 ++++----- 6 files changed, 9 insertions(+), 24 deletions(-) diff --git a/stock_average_daily_sale/__manifest__.py b/stock_average_daily_sale/__manifest__.py index 582a9d34b..1c99adedf 100644 --- a/stock_average_daily_sale/__manifest__.py +++ b/stock_average_daily_sale/__manifest__.py @@ -15,7 +15,6 @@ "product_abc_classification", "product_abc_classification_sale_stock", "product_route_mto", - "stock_location_zone", ], "data": [ "security/stock_average_daily_sale_config.xml", diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 0dfb148cd..41ed3e0b7 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -87,9 +87,6 @@ class StockAverageDailySale(models.Model): string="Daily Qty Standard Deviation", required=True ) warehouse_id = fields.Many2one(comodel_name="stock.warehouse", required=True) - zone_location_id = fields.Many2one( - string="Location Zone", comodel_name="stock.location", index=True - ) qty_in_stock = fields.Float( string="Quantity in stock", digits="Product Unit of Measure", @@ -187,7 +184,6 @@ def _create_materialized_view(self): sm.product_id, sm.product_uom_qty, sl_src.warehouse_id, - sl_src.zone_location_id, (avg(product_uom_qty) OVER pid - (stddev_samp(product_uom_qty) OVER pid * cfg.standard_deviation_exclude_factor) ) as lower_bound, @@ -220,7 +216,6 @@ def _create_materialized_view(self): concat(warehouse_id, product_id)::integer as id, product_id, warehouse_id, - zone_location_id, (avg(product_uom_qty) FILTER (WHERE product_uom_qty BETWEEN lower_bound AND upper_bound OR standard_deviation = 0) )::numeric AS average_qty_by_sale, @@ -235,7 +230,7 @@ def _create_materialized_view(self): config_id, nrb_days_without_sat_sun FROM deliveries_last - GROUP BY product_id, warehouse_id, zone_location_id, standard_deviation, nrb_days_without_sat_sun, date_from, date_to, config_id + GROUP BY product_id, warehouse_id, standard_deviation, nrb_days_without_sat_sun, date_from, date_to, config_id ), -- Compute the stock by product in locations under stock stock_qty AS ( @@ -277,7 +272,6 @@ def _create_materialized_view(self): t.id, t.product_id, t.warehouse_id, - t.zone_location_id, average_qty_by_sale, average_daily_sales_count, average_qty_by_sale * average_daily_sales_count as average_daily_qty, diff --git a/stock_average_daily_sale/models/stock_average_daily_sale_config.py b/stock_average_daily_sale/models/stock_average_daily_sale_config.py index 4b2521f5c..55f5ec552 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale_config.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale_config.py @@ -32,12 +32,6 @@ class StockAverageDailySaleConfig(models.Model): ), readonly=True, ) - stock_location_kind = fields.Selection( - selection=lambda self: self.env["stock.location"] - ._fields["location_kind"] - .selection, - default="zone", - ) period_name = fields.Selection( string="Period analyzed unit", selection=[ diff --git a/stock_average_daily_sale/tests/common.py b/stock_average_daily_sale/tests/common.py index 05d468561..0cb17286e 100644 --- a/stock_average_daily_sale/tests/common.py +++ b/stock_average_daily_sale/tests/common.py @@ -26,7 +26,6 @@ def setUpClass(cls): cls.location_zone = cls.location_obj.create( { "name": "Zone Location", - "is_zone": True, "location_id": cls.warehouse_0.lot_stock_id.id, } ) diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 905babb8b..4a78a3947 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -54,7 +54,7 @@ def test_average_sale(self): "nbr_sales": 1.0, "average_qty_by_sale": 10.0, "qty_in_stock": 40.0, - "zone_location_id": self.location_zone.id, + "warehouse_id": self.warehouse_0.id, } ], ) @@ -68,7 +68,7 @@ def test_average_sale(self): "nbr_sales": 1.0, "average_qty_by_sale": 12.0, "qty_in_stock": 48.0, - "zone_location_id": self.location_zone.id, + "warehouse_id": self.warehouse_0.id, } ], ) @@ -128,7 +128,7 @@ def test_average_sale_multiple(self): { "nbr_sales": 3.0, "qty_in_stock": 19.0, - "zone_location_id": self.location_zone.id, + "warehouse_id": self.warehouse_0.id, } ], ) @@ -144,7 +144,7 @@ def test_average_sale_multiple(self): "nbr_sales": 2.0, "average_qty_by_sale": 8.0, "qty_in_stock": 44.0, - "zone_location_id": self.location_zone.id, + "warehouse_id": self.warehouse_0.id, } ], ) diff --git a/stock_average_daily_sale/views/stock_average_daily_sale.xml b/stock_average_daily_sale/views/stock_average_daily_sale.xml index a92bb2b6c..9aca7b5ec 100644 --- a/stock_average_daily_sale/views/stock_average_daily_sale.xml +++ b/stock_average_daily_sale/views/stock_average_daily_sale.xml @@ -8,7 +8,7 @@ - + @@ -56,7 +56,6 @@ - From a5012ccb3b16e937aa6b0fdc72cd1f163d593977 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 13 Jul 2023 14:30:07 +0200 Subject: [PATCH 05/15] [IMP] stock_average_daily_sale: Improve tests --- .../tests/test_average_daily_sale.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 4a78a3947..36dbf1784 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -26,6 +26,7 @@ def setUpClass(cls): def test_average_sale(self): # By default, products have abc_storage == 'b' # So, the averages should correspond to 'b' one + move_1_date = Date.to_string(self.now - relativedelta(weeks=11)) with freeze_time(move_1_date): move = self._create_move(self.product_1, self.location_bin, 10.0) @@ -180,3 +181,23 @@ def test_average_sale_profile_a(self): [("product_id", "=", self.product_2.id)] ) self.assertFalse(avg_product_2) + + def test_view(self): + # Check no exception is raised if materialized view is not loaded + with self.assertLogs( + "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale" + ) as log: + result = self.env["stock.average.daily.sale"].search_read( + [("product_id", "=", self.product_1.id)] + ) + self.assertFalse(result) + self.assertIn("The materialized view has not been populated", log.output[0]) + + def test_view_refreshed(self): + self._refresh() + with self.assertNoLogs( + "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale" + ): + self.env["stock.average.daily.sale"].search_read( + [("product_id", "=", self.product_1.id)] + ) From d4fb211f0b074331e1ffcdb921eacc6108d4b9d6 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 13 Jul 2023 14:48:58 +0200 Subject: [PATCH 06/15] [IMP] stock_average_daily_sale: Improve module helps / change field name to recommended_qty --- .../models/stock_average_daily_sale.py | 38 ++++++++++--------- .../tests/test_average_daily_sale.py | 3 ++ .../views/stock_average_daily_sale.xml | 2 +- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 41ed3e0b7..ffe6d55b8 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -31,15 +31,23 @@ class StockAverageDailySale(models.Model): selection=ABC_SELECTION, required=True, readonly=True, index=True ) average_daily_sales_count = fields.Float( - required=True, digits="Product Unit of Measure" + required=True, + digits="Product Unit of Measure", + help="How much deliveries on average for this product on the period. " + "The spikes are excluded from the average computation.", ) average_qty_by_sale = fields.Float( - required=True, digits="Product Unit of Measure", help="Average Daily Sales Qty" + required=True, + digits="Product Unit of Measure", + help="The quantity " + "delivered on average for one delivery of this product on the period. " + "The spikes are excluded from the average computation.", ) average_daily_qty = fields.Float( digits="Product Unit of Measure", required=True, - help="The average daily qty sold", + help="The quantity delivered on average on one day for this product on " + "the period. The spikes are excluded from the average computation.", ) config_id = fields.Many2one( string="Stock Average Daily Sale Configuration", @@ -54,27 +62,23 @@ class StockAverageDailySale(models.Model): store=True, index=True, ) - nbr_sales = fields.Integer(string="Number of Sales", required=True) + nbr_sales = fields.Integer( + string="Number of Sales", + required=True, + help="The total amount of deliveries for this product over the complete period", + ) product_id = fields.Many2one( comodel_name="product.product", string="Product", required=True, index=True ) safety = fields.Float( required=True, - help="daily standard deviation * safety factor * sqrt(nbr days into period " - "without saturday and sunday", - ) - safety_bin_min_qty = fields.Float( - required=True, - digits="Product Unit of Measure", - help="Minimal safety qty into a bin location computed as: " - "average daily qty * number days in stock * safety", + help="Safety stock to cover the variability of the quantity delivered " + "each day. Formula: daily standard deviation * safety factor * sqrt(nbr days in the period)", ) - safety_bin_min_qty_old = fields.Float( + recommended_qty = fields.Float( required=True, digits="Product Unit of Measure", - help="Minimal value for the safety qty. Computed as: " - "number days in stock * GREATEST(average daily sales count, 1) * " - "(average qty by sale + (stddev * safety factor))", + help="Minimal recommended quantity in stock. Formula: average daily qty * number days in stock + safety", ) sale_ok = fields.Boolean( string="Can be Sold", @@ -292,7 +296,7 @@ def _create_materialized_view(self): GREATEST( (cfg.number_days_qty_in_stock * average_qty_by_sale * average_daily_sales_count) + (ds.daily_standard_deviation * cfg.safety_factor * sqrt(nrb_days_without_sat_sun)), (cfg.number_days_qty_in_stock * average_qty_by_sale) - ) as safety_bin_min_qty + ) as recommended_qty FROM averages t JOIN daily_standard_deviation ds on ds.id= t.id JOIN stock_average_daily_sale_config cfg on cfg.id = t.config_id diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 36dbf1784..97c085766 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -55,6 +55,7 @@ def test_average_sale(self): "nbr_sales": 1.0, "average_qty_by_sale": 10.0, "qty_in_stock": 40.0, + "recommended_qty": 20.0, "warehouse_id": self.warehouse_0.id, } ], @@ -69,6 +70,7 @@ def test_average_sale(self): "nbr_sales": 1.0, "average_qty_by_sale": 12.0, "qty_in_stock": 48.0, + "recommended_qty": 24.0, "warehouse_id": self.warehouse_0.id, } ], @@ -133,6 +135,7 @@ def test_average_sale_multiple(self): } ], ) + self.assertAlmostEqual(20.67, avg_product_1.recommended_qty, places=2) self.assertAlmostEqual(10.33, avg_product_1.average_qty_by_sale, places=2) avg_product_2 = self.env["stock.average.daily.sale"].search( diff --git a/stock_average_daily_sale/views/stock_average_daily_sale.xml b/stock_average_daily_sale/views/stock_average_daily_sale.xml index 9aca7b5ec..b58b1e08e 100644 --- a/stock_average_daily_sale/views/stock_average_daily_sale.xml +++ b/stock_average_daily_sale/views/stock_average_daily_sale.xml @@ -50,7 +50,7 @@ - + From f556d0011bc0fd1e5f495886c361e830768b806c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 31 Jul 2023 18:22:08 +0200 Subject: [PATCH 07/15] [FIX] stock_average_daily_sale: Don't exclude moves on priority By default the priority in odoo is set to '0'. Before this change the daily sale was never computed for moves done in a normal process. There is no need to filter out moves based on the priority from the computation. --- stock_average_daily_sale/models/stock_average_daily_sale.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index ffe6d55b8..46e934a41 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -209,7 +209,6 @@ def _create_materialized_view(self): WHERE sl_src.usage in ('view', 'internal') AND sl_dest.usage = 'customer' - AND sm.priority > '0' AND sm.date BETWEEN cfg.date_from AND cfg.date_to AND sm.state = 'done' WINDOW pid AS (PARTITION BY sm.product_id, sm.warehouse_id) From 940263e70fb94d6df2eb93d4252b88bebb93fcd4 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Fri, 15 Sep 2023 15:47:03 +0200 Subject: [PATCH 08/15] [IMP] stock_average_daily_sale: Don't use Odoo demo data --- .../wizards/stock_average_daily_sale_demo.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py index be60f4b7b..b720073de 100644 --- a/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py +++ b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py @@ -83,9 +83,19 @@ def _action_create_data(self): _("You cannot call the _action_create_data() on production database.") ) return - product = self.env.ref("product.product_product_25") + product = self.env["product.product"].create( + { + "name": "Product Test 1", + "type": "product", + } + ) self._create_movement(product) - product = self.env.ref("product.product_product_27") + product = self.env["product.product"].create( + { + "name": "Product Test 2", + "type": "product", + } + ) self._create_movement(product) self.env["stock.average.daily.sale"].refresh_view() From 17452027ea38f242a80a3987ecbca5cb3df875b6 Mon Sep 17 00:00:00 2001 From: Bejaoui Souheil Date: Wed, 4 Oct 2023 14:21:49 +0200 Subject: [PATCH 09/15] [IMP] stock_average_daily_sale: add contributor Co-authored-by: Jacques-Etienne Baudoux --- stock_average_daily_sale/__manifest__.py | 2 +- stock_average_daily_sale/readme/CONTRIBUTORS.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/stock_average_daily_sale/__manifest__.py b/stock_average_daily_sale/__manifest__.py index 1c99adedf..a76c0145b 100644 --- a/stock_average_daily_sale/__manifest__.py +++ b/stock_average_daily_sale/__manifest__.py @@ -7,7 +7,7 @@ Allows to gather delivered products average on daily basis""", "version": "16.0.1.0.0", "license": "AGPL-3", - "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "author": "ACSONE SA/NV,BCIM,Odoo Community Association (OCA)", "website": "https://github.com/OCA/stock-logistics-reporting", "depends": [ "sale", diff --git a/stock_average_daily_sale/readme/CONTRIBUTORS.rst b/stock_average_daily_sale/readme/CONTRIBUTORS.rst index 7c2997e3f..4c53b088d 100644 --- a/stock_average_daily_sale/readme/CONTRIBUTORS.rst +++ b/stock_average_daily_sale/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Laurent Mignon * Denis Roussel +* Jacques-Etienne Baudoux (BCIM) From ae800387140059aa5439e78d3677c38639091519 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Tue, 10 Oct 2023 11:51:30 +0200 Subject: [PATCH 10/15] [IMP] stock_average_daily_sale: Avoid errors in all searches and return browse record --- .../models/stock_average_daily_sale.py | 16 +++++----------- .../tests/test_average_daily_sale.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 46e934a41..57b18e8c2 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -101,7 +101,8 @@ class StockAverageDailySale(models.Model): @api.model def _check_view(self): try: - self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) + with self.env.cr.savepoint(): + self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) return True except ObjectNotInPrerequisiteState: _logger.warning( @@ -113,18 +114,11 @@ def _check_view(self): # pylint: disable=redefined-outer-name @api.model - def search_read( - self, domain=None, fields=None, offset=0, limit=None, order=None, **read_kwargs - ): + def search(self, domain, offset=0, limit=None, order=None, count=False): if not self._check_view(): return self.browse() - return super().search_read( - domain=domain, - fields=fields, - offset=offset, - limit=limit, - order=order, - **read_kwargs + return super().search( + domain=domain, offset=offset, limit=limit, order=order, count=count ) @api.model diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 97c085766..2b9128b8d 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -1,5 +1,6 @@ # Copyright 2022 ACSONE SA/NV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + from dateutil.relativedelta import relativedelta from freezegun import freeze_time @@ -204,3 +205,16 @@ def test_view_refreshed(self): self.env["stock.average.daily.sale"].search_read( [("product_id", "=", self.product_1.id)] ) + + def test_view_not_refreshed(self): + with self.assertLogs( + "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale", + level="WARNING", + ) as logger: + self.env["stock.average.daily.sale"].search( + [("product_id", "=", self.product_1.id)] + ) + self.assertIn( + str("The materialized view has not been populated. Launch the cron."), + str(logger.output), + ) From 08a8786cf8509e71752fa36917817f3c5b8050a1 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 11 Oct 2023 12:25:07 +0200 Subject: [PATCH 11/15] [FIX] stock_average_daily_sale: Use a new cursor to avoid closed one --- .../models/stock_average_daily_sale.py | 9 ++++++--- .../tests/test_average_daily_sale.py | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 57b18e8c2..c6321eb73 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -6,7 +6,7 @@ from psycopg2.errors import ObjectNotInPrerequisiteState from psycopg2.extensions import AsIs -from odoo import _, api, fields, models +from odoo import _, api, fields, models, registry from odoo.addons.stock_storage_type_putaway_abc.models.stock_location import ( ABC_SELECTION, @@ -101,8 +101,9 @@ class StockAverageDailySale(models.Model): @api.model def _check_view(self): try: - with self.env.cr.savepoint(): - self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) + cr = registry(self._cr.dbname).cursor() + new_self = self.with_env(self.env(cr=cr)) # TDE FIXME + new_self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) return True except ObjectNotInPrerequisiteState: _logger.warning( @@ -111,6 +112,8 @@ def _check_view(self): return False except Exception as e: raise e + finally: + new_self.env.cr.close() # pylint: disable=redefined-outer-name @api.model diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 2b9128b8d..758310b08 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -218,3 +218,9 @@ def test_view_not_refreshed(self): str("The materialized view has not been populated. Launch the cron."), str(logger.output), ) + # Check if we can still query database + product = self.env["product.product"].search([("id", "=", self.product_1.id)]) + self.assertEqual( + product, + self.product_1, + ) From f2f8d2600a1422c043ee191d365ebcd381c0048a Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 11 Oct 2023 12:32:31 +0200 Subject: [PATCH 12/15] [FIX] stock_average_daily_sale: Don't test the not refreshed materialized view --- .../tests/test_average_daily_sale.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 758310b08..0c4279fb5 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -205,22 +205,3 @@ def test_view_refreshed(self): self.env["stock.average.daily.sale"].search_read( [("product_id", "=", self.product_1.id)] ) - - def test_view_not_refreshed(self): - with self.assertLogs( - "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale", - level="WARNING", - ) as logger: - self.env["stock.average.daily.sale"].search( - [("product_id", "=", self.product_1.id)] - ) - self.assertIn( - str("The materialized view has not been populated. Launch the cron."), - str(logger.output), - ) - # Check if we can still query database - product = self.env["product.product"].search([("id", "=", self.product_1.id)]) - self.assertEqual( - product, - self.product_1, - ) From a1dcdd64083ca95e36dc977c36cb28c2ecceefc3 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 11 Oct 2023 13:41:14 +0200 Subject: [PATCH 13/15] [FIX] stock_average_daily_sale: Don't check the view existence in tests As tests create a Savepoint, there is a concurrent query when checking the view existence. --- .../models/stock_average_daily_sale.py | 3 ++- .../tests/test_average_daily_sale.py | 11 ----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index c6321eb73..1b6793d46 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -7,6 +7,7 @@ from psycopg2.extensions import AsIs from odoo import _, api, fields, models, registry +from odoo.tools import config from odoo.addons.stock_storage_type_putaway_abc.models.stock_location import ( ABC_SELECTION, @@ -118,7 +119,7 @@ def _check_view(self): # pylint: disable=redefined-outer-name @api.model def search(self, domain, offset=0, limit=None, order=None, count=False): - if not self._check_view(): + if not config["test_enable"] and not self._check_view(): return self.browse() return super().search( domain=domain, offset=offset, limit=limit, order=order, count=count diff --git a/stock_average_daily_sale/tests/test_average_daily_sale.py b/stock_average_daily_sale/tests/test_average_daily_sale.py index 0c4279fb5..5a2d42f26 100644 --- a/stock_average_daily_sale/tests/test_average_daily_sale.py +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -186,17 +186,6 @@ def test_average_sale_profile_a(self): ) self.assertFalse(avg_product_2) - def test_view(self): - # Check no exception is raised if materialized view is not loaded - with self.assertLogs( - "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale" - ) as log: - result = self.env["stock.average.daily.sale"].search_read( - [("product_id", "=", self.product_1.id)] - ) - self.assertFalse(result) - self.assertIn("The materialized view has not been populated", log.output[0]) - def test_view_refreshed(self): self._refresh() with self.assertNoLogs( From 90c0affddef9f4008e7e1e475c26b71c827e2a37 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 11 Oct 2023 16:15:37 +0200 Subject: [PATCH 14/15] [FIX] stock_average_daily_sale: Don't rely on tuples as id As for x reason, some products appear more than one time in the report, id generated by the concatenation of product_id and warehouse_id is irrelevant (as duplicate values possible). Use row_number() instead --- stock_average_daily_sale/models/stock_average_daily_sale.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index 1b6793d46..d9129115c 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -214,7 +214,8 @@ def _create_materialized_view(self): averages AS( SELECT - concat(warehouse_id, product_id)::integer as id, + row_number() over (order by product_id) as id, + concat(warehouse_id, product_id)::integer as window_id, product_id, warehouse_id, (avg(product_uom_qty) FILTER @@ -295,7 +296,7 @@ def _create_materialized_view(self): (cfg.number_days_qty_in_stock * average_qty_by_sale) ) as recommended_qty FROM averages t - JOIN daily_standard_deviation ds on ds.id= t.id + JOIN daily_standard_deviation ds on ds.id= t.window_id JOIN stock_average_daily_sale_config cfg on cfg.id = t.config_id JOIN stock_qty sqty on sqty.pp_id = t.product_id AND t.warehouse_id = sqty.warehouse_id JOIN product_product pp on pp.id = t.product_id From d46c24f3bfbbef5485331ebe628c2224e1bcb5d0 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Tue, 21 Nov 2023 15:35:44 +0100 Subject: [PATCH 15/15] [IMP] stock_average_daily_sale: avoid concurrent update --- .../models/stock_average_daily_sale.py | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/stock_average_daily_sale/models/stock_average_daily_sale.py b/stock_average_daily_sale/models/stock_average_daily_sale.py index d9129115c..bdc1d90bd 100644 --- a/stock_average_daily_sale/models/stock_average_daily_sale.py +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +from contextlib import closing from psycopg2.errors import ObjectNotInPrerequisiteState from psycopg2.extensions import AsIs @@ -99,22 +100,34 @@ class StockAverageDailySale(models.Model): required=True, ) + @classmethod + def _check_materialize_view_populated(cls, cr): + """ + Check if the materialized view is populated + + :param cr: database cursor + :return: True if the materialized view is populated, False otherwise + """ + cr.execute( + "SELECT ispopulated FROM pg_matviews WHERE matviewname = %s;", + (cls._table,), + ) + records = cr.fetchone() + return records and records[0] + @api.model def _check_view(self): - try: - cr = registry(self._cr.dbname).cursor() - new_self = self.with_env(self.env(cr=cr)) # TDE FIXME - new_self.env.cr.execute("SELECT COUNT(1) FROM %s", (AsIs(self._table),)) - return True - except ObjectNotInPrerequisiteState: - _logger.warning( - _("The materialized view has not been populated. Launch the cron.") - ) - return False - except Exception as e: - raise e - finally: - new_self.env.cr.close() + cr = registry(self._cr.dbname).cursor() + with closing(cr): + try: + return self._check_materialize_view_populated(cr) + except ObjectNotInPrerequisiteState: + _logger.warning( + _("The materialized view has not been populated. Launch the cron.") + ) + return False + except Exception as e: + raise e # pylint: disable=redefined-outer-name @api.model @@ -141,7 +154,16 @@ def set_refresh_date(self, date=None): @api.model def refresh_view(self): - self.env.cr.execute("refresh materialized view %s", (AsIs(self._table),)) + concurrently = "" + if self._check_materialize_view_populated(self.env.cr): + concurrently = "CONCURRENTLY" + self.env.cr.execute( + "refresh materialized view %s %s", + ( + AsIs(concurrently), + AsIs(self._table), + ), + ) self.set_refresh_date() def _create_materialized_view(self):