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/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/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..64face17a --- /dev/null +++ b/stock_average_daily_sale/__init__.py @@ -0,0 +1 @@ +from . import models, wizards diff --git a/stock_average_daily_sale/__manifest__.py b/stock_average_daily_sale/__manifest__.py new file mode 100644 index 000000000..a76c0145b --- /dev/null +++ b/stock_average_daily_sale/__manifest__.py @@ -0,0 +1,34 @@ +# 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,BCIM,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-reporting", + "depends": [ + "sale", + "stock_storage_type_putaway_abc", + "product_abc_classification", + "product_abc_classification_sale_stock", + "product_route_mto", + ], + "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/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..2b90e8449 --- /dev/null +++ b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml @@ -0,0 +1,50 @@ + + + + + + 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/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 new file mode 100644 index 000000000..756ee7b45 --- /dev/null +++ b/stock_average_daily_sale/models/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..bdc1d90bd --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -0,0 +1,354 @@ +# Copyright 2021 ACSONE SA/NV +# 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 + +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, +) + +_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_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 + ) + average_daily_sales_count = fields.Float( + 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="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 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", + 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, + 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="Safety stock to cover the variability of the quantity delivered " + "each day. Formula: daily standard deviation * safety factor * sqrt(nbr days in the period)", + ) + recommended_qty = fields.Float( + required=True, + digits="Product Unit of Measure", + help="Minimal recommended quantity in stock. Formula: average daily qty * number days in stock + safety", + ) + 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) + qty_in_stock = fields.Float( + string="Quantity in stock", + digits="Product Unit of Measure", + help="All stock locations, reserved product included", + 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): + 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 + def search(self, domain, offset=0, limit=None, order=None, count=False): + 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 + ) + + @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): + 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): + 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, + (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.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 + 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 + (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, 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, + 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, + cfg.abc_classification_profile_id, + 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 recommended_qty + FROM averages t + 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 + 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..55f5ec552 --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale_config.py @@ -0,0 +1,49 @@ +# 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_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 + ) + 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, + ) + 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..4c53b088d --- /dev/null +++ b/stock_average_daily_sale/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Laurent Mignon +* Denis Roussel +* Jacques-Etienne Baudoux (BCIM) 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/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/static/description/icon.png b/stock_average_daily_sale/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/stock_average_daily_sale/static/description/icon.png differ 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..0cb17286e --- /dev/null +++ b/stock_average_daily_sale/tests/common.py @@ -0,0 +1,103 @@ +# 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", + "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..5a2d42f26 --- /dev/null +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -0,0 +1,196 @@ +# 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, + "recommended_qty": 20.0, + "warehouse_id": self.warehouse_0.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, + "recommended_qty": 24.0, + "warehouse_id": self.warehouse_0.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, + "warehouse_id": self.warehouse_0.id, + } + ], + ) + 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( + [("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, + "warehouse_id": self.warehouse_0.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) + + 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)] + ) 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..e958473a7 --- /dev/null +++ b/stock_average_daily_sale/views/abc_classification_profile.xml @@ -0,0 +1,22 @@ + + + + + abc.classification.profile.form (in stock_average_daily_sale) + abc.classification.profile + + + + + + + + + + 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..b58b1e08e --- /dev/null +++ b/stock_average_daily_sale/views/stock_average_daily_sale.xml @@ -0,0 +1,84 @@ + + + + + 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 + + + + + + + + + + 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..b720073de --- /dev/null +++ b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py @@ -0,0 +1,101 @@ +# 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["product.product"].create( + { + "name": "Product Test 1", + "type": "product", + } + ) + self._create_movement(product) + product = self.env["product.product"].create( + { + "name": "Product Test 2", + "type": "product", + } + ) + self._create_movement(product) + + self.env["stock.average.daily.sale"].refresh_view()