diff --git a/stock_average_daily_sale/README.rst b/stock_average_daily_sale/README.rst new file mode 100644 index 0000000..c51ffdd --- /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 0000000..0650744 --- /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 0000000..8194f54 --- /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 0000000..de4c670 --- /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 0000000..4a8e836 --- /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 0000000..f6a34f5 --- /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 0000000..5eb4d9c --- /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 0000000..95496a6 --- /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 0000000..2d34710 --- /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 0000000..e7838a5 --- /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 0000000..7c2997e --- /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 0000000..a5b0d1d --- /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 0000000..f65b11f --- /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 0000000..9dc38ed --- /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 0000000..2fad7c2 --- /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 0000000..c9b326a --- /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 0000000..3a0328b 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 0000000..e5a4f18 --- /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 0000000..822be55 --- /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 0000000..05d4685 --- /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 0000000..905babb --- /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 0000000..a92bb2b --- /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 0000000..ccda26f --- /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 0000000..9715f01 --- /dev/null +++ b/stock_average_daily_sale/views/stock_warehouse.xml @@ -0,0 +1,18 @@ + + + + + + stock.warehouse + stock.warehouse + + + + + + + + + +