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..1c854b200 --- /dev/null +++ b/stock_average_daily_sale/README.rst @@ -0,0 +1,135 @@ +======================== +Stock Average Daily Sale +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0613e9cd1f066c6b743fa81c806eba998998cdd63edf7603d1f151761423fd45 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/14.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-14-0/stock-logistics-reporting-14-0-stock_average_daily_sale + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-reporting&target_branch=14.0 + :alt: Try me on Runboat + +|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 +* A different root location for analysis per Warehouse + +**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 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 + +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 to smash 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 +* BCIM + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Denis Roussel +* Jacques-Etienne Baudoux (BCIM) + +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..7b8a2f774 --- /dev/null +++ b/stock_average_daily_sale/__manifest__.py @@ -0,0 +1,32 @@ +# 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": "14.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", + "stock_location_warehouse", # not needed in Odoo 16.0+ + "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/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..0963cea62 --- /dev/null +++ b/stock_average_daily_sale/demo/stock_average_daily_sale_config.xml @@ -0,0 +1,47 @@ + + + + + a + 2 + week + 3 + 0.3 + 2 + 1 + + + + + b + 13 + week + 3 + 0.3 + 2 + 1 + + + + + c + 26 + week + 3 + 0.3 + 2 + 1 + + + + 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/i18n/it.po b/stock_average_daily_sale/i18n/it.po new file mode 100644 index 000000000..ec944ebb6 --- /dev/null +++ b/stock_average_daily_sale/i18n/it.po @@ -0,0 +1,390 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_average_daily_sale +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-23 14:34+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__abc_classification_level +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__abc_classification_level +msgid "Abc Classification Level" +msgstr "Livello classificazione ABC" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/models/stock_average_daily_sale_config.py:0 +#: model:ir.model.constraint,message:stock_average_daily_sale.constraint_stock_average_daily_sale_config_abc_classification_level_unique +#, python-format +msgid "Abc Classification Level must be unique per warehouse" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__qty_in_stock +msgid "All stock locations, reserved product included" +msgstr "Tutte le ubicazioni di magazzino, inclusi prodotti prenotati" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_qty +msgid "Average Daily Qty" +msgstr "Q.tà media giornaliera" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_warehouse__average_daily_sale_root_location_id +msgid "Average Daily Sale Root Location" +msgstr "Ubicazione radice vendite giornaliere medie" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale +msgid "Average Daily Sale for Products" +msgstr "Vendite giornaliere medie per prodotto" + +#. module: stock_average_daily_sale +#: model:ir.actions.act_window,name:stock_average_daily_sale.stock_average_daily_sale_act_window +#: model:ir.ui.menu,name:stock_average_daily_sale.stock_average_daily_sale_menu +msgid "Average Daily Sales" +msgstr "Vendite giornaliere medie" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_sales_count +msgid "Average Daily Sales Count" +msgstr "Conteggio vendite giornalire medie" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_qty_by_sale +msgid "Average Qty By Sale" +msgstr "Q.tà media per vendita" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.view_warehouse +msgid "Average Sales" +msgstr "Vendite medie" + +#. module: stock_average_daily_sale +#: model:ir.actions.act_window,name:stock_average_daily_sale.stock_average_daily_sale_config_act_window +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale_config +#: model:ir.ui.menu,name:stock_average_daily_sale.stock_average_daily_sale_config_menu +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_average_daily_sale_config_form_view +msgid "Average daily sales computation parameters" +msgstr "Parametri calcolo vendite giornaliere medie" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__sale_ok +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Can be Sold" +msgstr "Può essere venduto" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__company_id +msgid "Company" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__create_uid +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__create_date +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__daily_standard_deviation +msgid "Daily Qty Standard Deviation" +msgstr "Deviazione standard q.tà giornaliera" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__day +msgid "Days" +msgstr "Giorni" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__display_name +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__display_name +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__exclude_weekends +msgid "Exclude Weekends" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__date_from +msgid "From" +msgstr "Dal" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Group by..." +msgstr "Raggruppa per..." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_sales_count +msgid "" +"How much deliveries on average for this product on the period. The spikes " +"are excluded from the average computation." +msgstr "" +"Quante consegne medie per questo prodotto nel periodo. Le eccezioni sono " +"escluse dal calcolo medio." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__id +msgid "ID" +msgstr "ID" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale____last_update +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config____last_update +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__write_uid +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__write_date +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__recommended_qty +msgid "" +"Minimal recommended quantity in stock. Formula: average daily qty * number " +"days in stock + safety" +msgstr "" +"Quantità minima a magazzino raccomandata. Formula: q.tà media giornaliera * " +"numero di giorni in magazzino + sicurezza" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__month +msgid "Months" +msgstr "Mesi" + +#. module: stock_average_daily_sale +#: model_terms:ir.actions.act_window,help:stock_average_daily_sale.stock_average_daily_sale_act_window +msgid "" +"No data found.\n" +"\n" +" You maybe need to launch the cron to refresh the average " +"daily sale data." +msgstr "" +"Nessun dato trovato.\n" +"\n" +" Potrebbe essere necessario rilanciare il cron per aggiornare " +"i dati della la quatità di vendita media giornaliera." + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Normal product" +msgstr "Prodotto normale" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__nbr_sales +msgid "Number of Sales" +msgstr "Numero di vendite" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__number_days_qty_in_stock +msgid "Number of days of quantities in stock" +msgstr "Numero di giorni di quantità in magazzino" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__is_mto +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "On Order" +msgstr "Su ordine" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__period_name +msgid "Period analyzed unit" +msgstr "Unità analizzate nel periodo" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__period_value +msgid "Period analyzed value" +msgstr "Valore analizzato nel periodo" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__product_id +msgid "Product" +msgstr "Prodotto" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__standard_deviation +msgid "Qty Standard Deviation" +msgstr "Deviazione standard q.tà" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__qty_in_stock +msgid "Quantity in stock" +msgstr "Quantità a magazzino" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__recommended_qty +msgid "Recommended Qty" +msgstr "Q.tà suggerita" + +#. module: stock_average_daily_sale +#: model:ir.actions.server,name:stock_average_daily_sale.refresh_materialized_view_ir_actions_server +#: model:ir.cron,cron_name:stock_average_daily_sale.refresh_materialized_view +msgid "Refresh average daily sales materialized view" +msgstr "Aggiorna vista vendite medie giornaliere realizzate" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__safety +msgid "Safety" +msgstr "Sicurezza" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__safety_factor +msgid "Safety Factor" +msgstr "Fattore di sicurezza" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__safety +msgid "" +"Safety stock to cover the variability of the quantity delivered each day. " +"Formula: daily standard deviation * safety factor * sqrt(nbr days in the " +"period)" +msgstr "" +"Giacenza di sicurezza per coprire la variabilità delle quantità consegnate " +"ogni giorno. Formula: deviazionestandard giornaliera * fattore di sicurezza " +"* radice(numero giornii nel periodo)" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Search Average Daily Sale" +msgstr "Cerca vendita giornaliera media" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale_config__exclude_weekends +msgid "" +"Set to True only if you do not expect any orders/deliveries during the " +"weekends. If set to True, stock moves done on weekends won't be taken into " +"account to calculate the average daily usage" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__sale_ok +msgid "Specify if the product can be selected in a sales order line." +msgstr "" +"Indica se il prodotto può essre selezionato iun una riga ordine di vendita." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__standard_deviation_exclude_factor +msgid "Standard Deviation Exclude Factor" +msgstr "Fattore esclusione deviazione standard" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__config_id +msgid "Stock Average Daily Sale Configuration" +msgstr "Configurazione magazzino vendite giornaliere medie" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/models/stock_average_daily_sale.py:0 +#, python-format +msgid "The materialized view has not been populated. Launch the cron." +msgstr "La vista consuntiva non è stata valorizzata. lanciare il cron." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_qty_by_sale +msgid "" +"The quantity delivered on average for one delivery of this product on the " +"period. The spikes are excluded from the average computation." +msgstr "" +"La quantità consegnata mediamente per la consegna di uno di questi prodotti " +"nel periodo. Le eccezioni sono escludedal calcolo medio." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_qty +msgid "" +"The quantity delivered on average on one day for this product on the period. " +"The spikes are excluded from the average computation." +msgstr "" +"La quantità consegnata mediamente in un giorno per questo prodotto nel " +"periodo. Le eccezioni sono escluse dal calcolo della media." + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__nbr_sales +msgid "" +"The total amount of deliveries for this product over the complete period" +msgstr "" +"Il valore totale delle consegne per questo prodotto nel periodo calcolato" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_warehouse__average_daily_sale_root_location_id +msgid "This is the root location for daily sale average stock computations" +msgstr "" +"Questa è l'ubicazione radice per il calcolo giornaliero delle vendite medie" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__date_to +msgid "To" +msgstr "Al" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_warehouse +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__warehouse_id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__warehouse_id +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Warehouse" +msgstr "Magazzino" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__week +msgid "Weeks" +msgstr "Settimane" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale_demo +msgid "Wizard to populate demo data with past moves for Average Daily Sale" +msgstr "" +"Procedura guidata per popolare i dati demo con movimenti passati per le " +"vendite medie giornaliere" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__year +msgid "Years" +msgstr "Anni" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py:0 +#, python-format +msgid "You cannot call the _action_create_data() on production database." +msgstr "" +"Non si può chiamare l'azione _action_create_data() nel database di " +"produzione." + +#~ msgid "Abc Classification Profile" +#~ msgstr "Profilo classificazione ABC" + +#~ msgid "Average Daily Sale" +#~ msgstr "Vendita giornaliera media" + +#~ msgid "Average Daily Sale Configurations" +#~ msgstr "Configurazioni vendite giornaliere medie" diff --git a/stock_average_daily_sale/i18n/stock_average_daily_sale.pot b/stock_average_daily_sale/i18n/stock_average_daily_sale.pot new file mode 100644 index 000000000..c296eb8f3 --- /dev/null +++ b/stock_average_daily_sale/i18n/stock_average_daily_sale.pot @@ -0,0 +1,355 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_average_daily_sale +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__abc_classification_level +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__abc_classification_level +msgid "Abc Classification Level" +msgstr "" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/models/stock_average_daily_sale_config.py:0 +#: model:ir.model.constraint,message:stock_average_daily_sale.constraint_stock_average_daily_sale_config_abc_classification_level_unique +#, python-format +msgid "Abc Classification Level must be unique per warehouse" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__qty_in_stock +msgid "All stock locations, reserved product included" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_qty +msgid "Average Daily Qty" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_warehouse__average_daily_sale_root_location_id +msgid "Average Daily Sale Root Location" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale +msgid "Average Daily Sale for Products" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.actions.act_window,name:stock_average_daily_sale.stock_average_daily_sale_act_window +#: model:ir.ui.menu,name:stock_average_daily_sale.stock_average_daily_sale_menu +msgid "Average Daily Sales" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_sales_count +msgid "Average Daily Sales Count" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__average_qty_by_sale +msgid "Average Qty By Sale" +msgstr "" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.view_warehouse +msgid "Average Sales" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.actions.act_window,name:stock_average_daily_sale.stock_average_daily_sale_config_act_window +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale_config +#: model:ir.ui.menu,name:stock_average_daily_sale.stock_average_daily_sale_config_menu +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_average_daily_sale_config_form_view +msgid "Average daily sales computation parameters" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__sale_ok +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Can be Sold" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__company_id +msgid "Company" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__create_uid +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__create_uid +msgid "Created by" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__create_date +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__create_date +msgid "Created on" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__daily_standard_deviation +msgid "Daily Qty Standard Deviation" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__day +msgid "Days" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__display_name +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__display_name +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__exclude_weekends +msgid "Exclude Weekends" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__date_from +msgid "From" +msgstr "" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Group by..." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_sales_count +msgid "" +"How much deliveries on average for this product on the period. The spikes " +"are excluded from the average computation." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__id +msgid "ID" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale____last_update +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config____last_update +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo____last_update +msgid "Last Modified on" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__write_uid +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__write_date +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_demo__write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__recommended_qty +msgid "" +"Minimal recommended quantity in stock. Formula: average daily qty * number " +"days in stock + safety" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__month +msgid "Months" +msgstr "" + +#. module: stock_average_daily_sale +#: model_terms:ir.actions.act_window,help:stock_average_daily_sale.stock_average_daily_sale_act_window +msgid "" +"No data found.\n" +"\n" +" You maybe need to launch the cron to refresh the average daily sale data." +msgstr "" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Normal product" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__nbr_sales +msgid "Number of Sales" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__number_days_qty_in_stock +msgid "Number of days of quantities in stock" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__is_mto +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "On Order" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__period_name +msgid "Period analyzed unit" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__period_value +msgid "Period analyzed value" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__product_id +msgid "Product" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__standard_deviation +msgid "Qty Standard Deviation" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__qty_in_stock +msgid "Quantity in stock" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__recommended_qty +msgid "Recommended Qty" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.actions.server,name:stock_average_daily_sale.refresh_materialized_view_ir_actions_server +#: model:ir.cron,cron_name:stock_average_daily_sale.refresh_materialized_view +msgid "Refresh average daily sales materialized view" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__safety +msgid "Safety" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__safety_factor +msgid "Safety Factor" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__safety +msgid "" +"Safety stock to cover the variability of the quantity delivered each day. " +"Formula: daily standard deviation * safety factor * sqrt(nbr days in the " +"period)" +msgstr "" + +#. module: stock_average_daily_sale +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Search Average Daily Sale" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale_config__exclude_weekends +msgid "" +"Set to True only if you do not expect any orders/deliveries during the " +"weekends. If set to True, stock moves done on weekends won't be taken into " +"account to calculate the average daily usage" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__sale_ok +msgid "Specify if the product can be selected in a sales order line." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__standard_deviation_exclude_factor +msgid "Standard Deviation Exclude Factor" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__config_id +msgid "Stock Average Daily Sale Configuration" +msgstr "" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/models/stock_average_daily_sale.py:0 +#, python-format +msgid "The materialized view has not been populated. Launch the cron." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_qty_by_sale +msgid "" +"The quantity delivered on average for one delivery of this product on the " +"period. The spikes are excluded from the average computation." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__average_daily_qty +msgid "" +"The quantity delivered on average on one day for this product on the period." +" The spikes are excluded from the average computation." +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_average_daily_sale__nbr_sales +msgid "" +"The total amount of deliveries for this product over the complete period" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,help:stock_average_daily_sale.field_stock_warehouse__average_daily_sale_root_location_id +msgid "This is the root location for daily sale average stock computations" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__date_to +msgid "To" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_warehouse +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale__warehouse_id +#: model:ir.model.fields,field_description:stock_average_daily_sale.field_stock_average_daily_sale_config__warehouse_id +#: model_terms:ir.ui.view,arch_db:stock_average_daily_sale.stock_daily_sale_search_view +msgid "Warehouse" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__week +msgid "Weeks" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model,name:stock_average_daily_sale.model_stock_average_daily_sale_demo +msgid "Wizard to populate demo data with past moves for Average Daily Sale" +msgstr "" + +#. module: stock_average_daily_sale +#: model:ir.model.fields.selection,name:stock_average_daily_sale.selection__stock_average_daily_sale_config__period_name__year +msgid "Years" +msgstr "" + +#. module: stock_average_daily_sale +#. odoo-python +#: code:addons/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py:0 +#, python-format +msgid "You cannot call the _action_create_data() on production database." +msgstr "" 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..8d5bd66e4 --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale.py @@ -0,0 +1,379 @@ +# 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.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_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): + if not self._check_materialize_view_populated(cr): + _logger.warning( + _("The materialized view has not been populated. Launch the cron.") + ) + return self._check_materialize_view_populated(cr) + + # pylint: disable=redefined-outer-name + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + if not config["test_enable"] and not self._check_view(): + return self.browse() + return super().search( + args=args, 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() + + # flake8: noqa: B950 + 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 days between start and end computed by + -- removing saturday and sunday if weekends should be excluded + (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 exclude_weekends = False + OR (exclude_weekends = True AND dd not in(0,6)) + ) AS nbr_days + 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.nbr_days, + cfg.date_from, + cfg.date_to, + cfg.exclude_weekends, + 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 in ('customer', 'production') + AND sm.date BETWEEN cfg.date_from AND cfg.date_to + AND sm.state = 'done' + AND sm.warehouse_id = cfg.warehouse_id + WINDOW pid AS (PARTITION BY sm.product_id, sm.warehouse_id) + ), + returns_last AS ( + SELECT + sm.product_id AS ret_product_id, + sm.product_uom_qty AS ret_product_uom_qty, + sl_src.warehouse_id AS ret_warehouse_id, + (avg(product_uom_qty) OVER pid + - (stddev_samp(product_uom_qty) OVER pid * cfg.standard_deviation_exclude_factor) + ) AS ret_lower_bound, + (avg(product_uom_qty) OVER pid + + (stddev_samp(product_uom_qty) OVER pid * cfg.standard_deviation_exclude_factor) + ) AS ret_upper_bound, + coalesce ((stddev_samp(product_uom_qty) OVER pid), 0) AS ret_standard_deviation, + cfg.nbr_days AS ret_nbr_days, + cfg.date_from AS ret_date_from, + cfg.date_to AS ret_date_to, + cfg.exclude_weekends AS ret_exclude_weekends, + cfg.id AS retconfig_id, + sm.date AS ret_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 ('inventory') + AND sl_dest.usage in ('internal') + AND sm.date BETWEEN cfg.date_from AND cfg.date_to + AND sm.state = 'done' + AND sm.warehouse_id = cfg.warehouse_id + 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 - COALESCE(ret_product_uom_qty, 0)) 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) + / nbr_days::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, + nbr_days + FROM deliveries_last + LEFT JOIN returns_last ON deliveries_last.product_id = returns_last.ret_product_id + GROUP BY product_id, warehouse_id, standard_deviation, nbr_days, 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 + 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 exclude_weekends = False OR (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, + sale_ok, + is_mto, + sqty.qty_in_stock as qty_in_stock, + ds.daily_standard_deviation, + ds.daily_standard_deviation * cfg.safety_factor * sqrt(nbr_days) as safety, + (cfg.number_days_qty_in_stock * average_qty_by_sale * average_daily_sales_count) + (ds.daily_standard_deviation * cfg.safety_factor * sqrt(nbr_days)) 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(nbr_days)), + (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..59ae09f31 --- /dev/null +++ b/stock_average_daily_sale/models/stock_average_daily_sale_config.py @@ -0,0 +1,62 @@ +# 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" + check_company_auto = True + + abc_classification_level = fields.Selection( + selection=ABC_SELECTION, required=True, default="b" + ) + company_id = fields.Many2one( + string="Company", + comodel_name="res.company", + required=True, + default=lambda self: self.env.company, + ) + 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 + ), + ) + exclude_weekends = fields.Boolean( + help="Set to True only if you do not expect any orders/deliveries during " + "the weekends. If set to True, stock moves done on weekends won't be " + "taken into account to calculate the average daily usage", + ) + 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) + + _sql_constraints = [ + ( + "abc_classification_level_unique", + "UNIQUE(abc_classification_level, warehouse_id)", + _("Abc Classification Level must be unique per warehouse"), + ) + ] 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..65a37484e --- /dev/null +++ b/stock_average_daily_sale/models/stock_warehouse.py @@ -0,0 +1,40 @@ +# 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, + check_company=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 + ): + if not warehouse.lot_stock_id: + continue + warehouse.average_daily_sale_root_location_id = warehouse.lot_stock_id + + @api.model + def create(self, vals): + # set the lot_stock_id of a newly created WH as an Average Daily Sale Root Location + warehouses = super().create(vals) + for warehouse in warehouses: + if vals.get("lot_stock_id") and not vals.get( + "average_daily_sale_root_location_id" + ): + warehouse.average_daily_sale_root_location_id = vals["lot_stock_id"] + return warehouses 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..f7e41a5e0 --- /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..7f4cfbbaa --- /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..67e21b8ea --- /dev/null +++ b/stock_average_daily_sale/static/description/index.html @@ -0,0 +1,495 @@ + + + + + +Stock Average Daily Sale + + + +
+

Stock Average Daily Sale

+ + +

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

+

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
  • +
+

Table of contents

+ +
+

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 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
    • +
    +
    +
  • +
+
+
+

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 to smash 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
  • +
  • BCIM
  • +
+
+
+

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..b63618a57 --- /dev/null +++ b/stock_average_daily_sale/tests/common.py @@ -0,0 +1,129 @@ +# 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.inventory"] + 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, + "usage": "internal", + } + ) + cls.location_bin_2 = cls.location_obj.create( + { + "name": "Bin Location 2", + "location_id": cls.location_area.id, + "usage": "internal", + } + ) + 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): + inventory = cls.env["stock.inventory"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_1.id, + "product_uom_id": cls.product_1.uom_id.id, + "product_qty": 50, + "location_id": cls.location_bin.id, + }, + ), + ( + 0, + 0, + { + "product_id": cls.product_2.id, + "product_uom_id": cls.product_2.uom_id.id, + "product_qty": 60, + "location_id": cls.location_bin_2.id, + }, + ), + ] + } + ) + inventory.action_start() + inventory.action_validate() + + @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, dest_location=None): + move = cls.move_obj.create( + { + "product_id": product.id, + "name": product.name, + "location_id": origin_location.id, + "warehouse_id": origin_location.warehouse_id.id, + "location_dest_id": dest_location.id + if dest_location + else cls.customers.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "priority": "1", + } + ) + # TODO: Check why this is necessary - it's in materialized view query + move.priority = "1" + return move + + @classmethod + def _refresh(cls): + # Flush to allow materialized view to be correctly populated + cls.env["stock.average.daily.sale"].flush() + 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..3a6c54547 --- /dev/null +++ b/stock_average_daily_sale/tests/test_average_daily_sale.py @@ -0,0 +1,295 @@ +# Copyright 2022 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.fields import Date +from odoo.tests.common import SavepointCase + +from .common import CommonAverageSaleTest + + +class TestAverageSale(CommonAverageSaleTest, SavepointCase): + @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, + "recommended_qty": 16.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() + # In python < 3.10 there is no assertNoLogs method so we use assertLogs + # Create a dummy warning and check if that is the only one + with self.assertLogs( + "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale", + level="DEBUG", + ) as cm: + logging.getLogger( + "odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale" + ).info("Dummy warning") + self.env["stock.average.daily.sale"].search_read( + [("product_id", "=", self.product_1.id)] + ) + # flake8: noqa: B950 + self.assertEqual( + [ + "INFO:odoo.addons.stock_average_daily_sale.models.stock_average_daily_sale:Dummy warning" + ], + cm.output, + ) + + def test_average_sale_with_returns(self): + # By default, products have abc_storage == 'b' + # So, the averages should correspond to 'b' one + + # Create moves for product_1 + 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() + + # create a "return" move - from inventory location to stock + 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.scrap_location, 7.0, self.location_bin + ) + 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": 2.0, + "average_qty_by_sale": 9.0, # should be 5.5 w/ return!!! + "recommended_qty": 18.0, # should be 11.0 w/ return!!! + "qty_in_stock": 39.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": 2.0, + "average_qty_by_sale": 8.0, + "recommended_qty": 16.0, + "qty_in_stock": 44.0, + "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 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..2c21b5822 --- /dev/null +++ b/stock_average_daily_sale/views/stock_average_daily_sale_config.xml @@ -0,0 +1,70 @@ + + + + + + stock.average.daily.sale.config.tree + stock.average.daily.sale.config + + + + + + + + + + + + + + + + + stock.average.daily.sale.config.form + stock.average.daily.sale.config + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + Average daily sales computation parameters + ir.actions.act_window + stock.average.daily.sale.config + tree,form + + [] + {} + + + + 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..de274ee7b --- /dev/null +++ b/stock_average_daily_sale/views/stock_warehouse.xml @@ -0,0 +1,21 @@ + + + + + + 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..ddccb14af --- /dev/null +++ b/stock_average_daily_sale/wizards/stock_average_daily_sale_demo.py @@ -0,0 +1,105 @@ +# 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, + "warehouse_id": suppliers.warehouse_id.id, + "location_dest_id": customers.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + } + ) + 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, + "warehouse_id": origin_location.warehouse_id.id, + "location_dest_id": customers.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "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() diff --git a/test-requirements.txt b/test-requirements.txt index 881bd9f26..2138fca9e 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,3 @@ freezegun + +odoo14-addon-stock_location_warehouse @ git+https://github.com/OCA/stock-logistics-warehouse.git@refs/pull/2162/head#subdirectory=setup/stock_location_warehouse