Merge PR #342 into 14.0

Signed-off-by jbaudoux
This commit is contained in:
OCA-git-bot
2024-12-06 13:39:36 +00:00
33 changed files with 2652 additions and 0 deletions

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
# generated from manifests external_dependencies
freezegun

View File

@@ -0,0 +1 @@
../../../../stock_average_daily_sale

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -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 <https://github.com/OCA/stock-logistics-reporting/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 <https://github.com/OCA/stock-logistics-reporting/issues/new?body=module:%20stock_average_daily_sale%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* ACSONE SA/NV
* BCIM
Contributors
~~~~~~~~~~~~
* Laurent Mignon <laurent.mignon@acsone.eu>
* Denis Roussel <denis.roussel@acsone.eu>
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
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 <https://github.com/OCA/stock-logistics-reporting/tree/14.0/stock_average_daily_sale>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1 @@
from . import models, wizards

View File

@@ -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",
],
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record forcecreate="True" id="refresh_materialized_view" model="ir.cron">
<field name="name">Refresh average daily sales materialized view</field>
<field name="active" eval="True" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">4</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False" />
<field name="model_id" ref="model_stock_average_daily_sale" />
<field name="code">model.refresh_view()</field>
<field name="state">code</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo noupdate="1">
<record
model="stock.average.daily.sale.config"
id="stock_average_daily_sale_config_level_a"
>
<field name="abc_classification_level">a</field>
<field name="period_value">2</field>
<field name="period_name">week</field>
<field name="standard_deviation_exclude_factor">3</field>
<field name="safety_factor">0.3</field>
<field name="number_days_qty_in_stock">2</field>
<field name="exclude_weekends">1</field>
<field name="warehouse_id" ref="stock.warehouse0" />
<field name="company_id" ref="base.main_company" />
</record>
<record
model="stock.average.daily.sale.config"
id="stock_average_daily_sale_config_level_b"
>
<field name="abc_classification_level">b</field>
<field name="period_value">13</field>
<field name="period_name">week</field>
<field name="standard_deviation_exclude_factor">3</field>
<field name="safety_factor">0.3</field>
<field name="number_days_qty_in_stock">2</field>
<field name="exclude_weekends">1</field>
<field name="warehouse_id" ref="stock.warehouse0" />
<field name="company_id" ref="base.main_company" />
</record>
<record
model="stock.average.daily.sale.config"
id="stock_average_daily_sale_config_level_c"
>
<field name="abc_classification_level">c</field>
<field name="period_value">26</field>
<field name="period_name">week</field>
<field name="standard_deviation_exclude_factor">3</field>
<field name="safety_factor">0.3</field>
<field name="number_days_qty_in_stock">2</field>
<field name="exclude_weekends">1</field>
<field name="warehouse_id" ref="stock.warehouse0" />
<field name="company_id" ref="base.main_company" />
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2023 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo noupdate="1">
<function model="stock.average.daily.sale.demo" name="_action_create_data" />
</odoo>

View File

@@ -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 <stefano.consolaro@mymage.it>\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"

View File

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

View File

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

View File

@@ -0,0 +1,347 @@
# 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() as a date is today at midnight so to include all moves
-- from yesterday, moves with date up to `NOW - 1 second` should be included
NOW()::date - '1 second'::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 coalesce(sm.warehouse_id, sl_src.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) 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
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()

View File

@@ -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"),
)
]

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
* Laurent Mignon <laurent.mignon@acsone.eu>
* Denis Roussel <denis.roussel@acsone.eu>
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>

View File

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

View File

@@ -0,0 +1,4 @@
16.0.1.0.0 (2023-01-13)
~~~~~~~~~~~~~~~~~~~~~~~
* [16.0][ADD] stock_average_daily_sale

View File

@@ -0,0 +1,2 @@
* Move the filter on saturday/sunday to configuration parameters
* An extensible data gathering query

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.model.access" id="stock_average_daily_sale_access_user">
<field name="name">stock.average.daily.sale access user</field>
<field name="model_id" ref="model_stock_average_daily_sale" />
<field name="group_id" ref="stock.group_stock_user" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.model.access" id="stock_average_daily_sale_config_access_user">
<field name="name">stock_average_daily_sale_config access user</field>
<field name="model_id" ref="model_stock_average_daily_sale_config" />
<field name="group_id" ref="stock.group_stock_user" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<record model="ir.model.access" id="stock_average_daily_sale_config_access_manager">
<field name="name">stock_average_daily_sale_config access manager</field>
<field name="model_id" ref="model_stock_average_daily_sale_config" />
<field name="group_id" ref="stock.group_stock_manager" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.model.access" id="stock_average_daily_sale_demo_access_user">
<field name="name">stock.average.daily.sale.demo access user</field>
<field name="model_id" ref="model_stock_average_daily_sale_demo" />
<field name="group_id" ref="base.group_user" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,495 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Stock Average Daily Sale</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="stock-average-daily-sale">
<h1 class="title">Stock Average Daily Sale</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:0613e9cd1f066c6b743fa81c806eba998998cdd63edf7603d1f151761423fd45
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/stock-logistics-reporting/tree/14.0/stock_average_daily_sale"><img alt="OCA/stock-logistics-reporting" src="https://img.shields.io/badge/github-OCA%2Fstock--logistics--reporting-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/stock-logistics-reporting-14-0/stock-logistics-reporting-14-0-stock_average_daily_sale"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-reporting&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>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).</p>
<p>You can add several configurations depending on the window you want to analyze.
So, you can define criteria to filter data:</p>
<ul class="simple">
<li>The Warehouse</li>
<li>The product ABC classification</li>
<li>The location kind (Zone, Area, Bin)</li>
<li>The amount of time to look backward (in days or weeks or months or years)</li>
</ul>
<p>Moreover, you can define:</p>
<ul class="simple">
<li>A safety factor</li>
<li>A standard deviation exclusion factor</li>
<li>A different root location for analysis per Warehouse</li>
</ul>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-2">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">16.0.1.0.0 (2023-01-13)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-7">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-8">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-9">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<ul>
<li><p class="first">To configure data analysis, you should go to Inventory &gt; Configuration &gt; Average daily sales computation parameters</p>
</li>
<li><p class="first">You need to fill in the following informations:</p>
<ul class="simple">
<li>The product ABC classification you want - see product_abc_classification module</li>
<li>The concerned Warehouse</li>
<li>The stock location kind (Zone, Area, Bin) - see stock_location_zone module</li>
<li>The period of time to analyze back (in days/weeks/months/years)</li>
<li>A standard deviation exclusion factor</li>
<li>A safety factor</li>
</ul>
</li>
<li><p class="first">Go to Configuration &gt; Technical &gt; Scheduled Actions &gt; Refresh average daily sales materialized view</p>
<p>By default, the scheduled action is set to refresh data each 4 hours. You can change
that depending on your needs.</p>
</li>
<li><p class="first">By default, the root location where analysis is done is the Warehouse stock location,
but you can change it.</p>
<blockquote>
<ul class="simple">
<li>Go to Inventory &gt; Configuration &gt; Warehouses</li>
<li>Change the Average Daily Sale Root Location field according your needs</li>
</ul>
</blockquote>
</li>
</ul>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#toc-entry-2">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Move the filter on saturday/sunday to configuration parameters</li>
<li>An extensible data gathering query</li>
</ul>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-4">16.0.1.0.0 (2023-01-13)</a></h2>
<ul class="simple">
<li>[16.0][ADD] stock_average_daily_sale</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-5">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/stock-logistics-reporting/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/stock-logistics-reporting/issues/new?body=module:%20stock_average_daily_sale%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-6">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-7">Authors</a></h2>
<ul class="simple">
<li>ACSONE SA/NV</li>
<li>BCIM</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-8">Contributors</a></h2>
<ul class="simple">
<li>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt;</li>
<li>Denis Roussel &lt;<a class="reference external" href="mailto:denis.roussel&#64;acsone.eu">denis.roussel&#64;acsone.eu</a>&gt;</li>
<li>Jacques-Etienne Baudoux (BCIM) &lt;<a class="reference external" href="mailto:je&#64;bcim.be">je&#64;bcim.be</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-9">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-reporting/tree/14.0/stock_average_daily_sale">OCA/stock-logistics-reporting</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
from . import test_average_daily_sale

View File

@@ -0,0 +1,119 @@
# 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}
)
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):
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):
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": 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()

View File

@@ -0,0 +1,213 @@
# 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, Datetime
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()
# `now` is today at midnight, checking move with `now - 1 second` datetime
# to ensure that all moves from yesterday are included in the calculation
move_2_date = Datetime.to_string(self.now - relativedelta(seconds=1))
with freeze_time(move_2_date):
move = self._create_move(self.product_2, self.location_bin_2, 12.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
self._refresh()
# self.env.flush_all()
avg_product_1 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_1.id)]
)
self.assertRecordValues(
avg_product_1,
[
{
"nbr_sales": 1.0,
"average_qty_by_sale": 10.0,
"qty_in_stock": 40.0,
"recommended_qty": 20.0,
"warehouse_id": self.warehouse_0.id,
}
],
)
avg_product_2 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_2.id)]
)
self.assertRecordValues(
avg_product_2,
[
{
"nbr_sales": 1.0,
"average_qty_by_sale": 12.0,
"qty_in_stock": 48.0,
"recommended_qty": 24.0,
"warehouse_id": self.warehouse_0.id,
}
],
)
def test_average_sale_multiple(self):
# By default, products have abc_storage == 'b'
# So, the averages should correspond to 'b' one
move_1_date = Date.to_string(self.now - relativedelta(weeks=11))
with freeze_time(move_1_date):
move = self._create_move(self.product_1, self.location_bin, 10.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
move_1_date = Date.to_string(self.now - relativedelta(weeks=10))
with freeze_time(move_1_date):
move = self._create_move(self.product_1, self.location_bin, 8.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
move_1_date = Date.to_string(self.now - relativedelta(weeks=9))
with freeze_time(move_1_date):
move = self._create_move(self.product_1, self.location_bin, 13.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
move_2_date = Date.to_string(self.now - relativedelta(weeks=9))
with freeze_time(move_2_date):
move = self._create_move(self.product_2, self.location_bin_2, 12.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
move_2_date = Date.to_string(self.now - relativedelta(weeks=8))
with freeze_time(move_2_date):
move = self._create_move(self.product_2, self.location_bin_2, 4.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
self._refresh()
avg_product_1 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_1.id)]
)
self.assertRecordValues(
avg_product_1,
[
{
"nbr_sales": 3.0,
"qty_in_stock": 19.0,
"warehouse_id": self.warehouse_0.id,
}
],
)
self.assertAlmostEqual(20.67, avg_product_1.recommended_qty, places=2)
self.assertAlmostEqual(10.33, avg_product_1.average_qty_by_sale, places=2)
avg_product_2 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_2.id)]
)
self.assertRecordValues(
avg_product_2,
[
{
"nbr_sales": 2.0,
"average_qty_by_sale": 8.0,
"qty_in_stock": 44.0,
"warehouse_id": self.warehouse_0.id,
}
],
)
def test_average_sale_profile_a(self):
# Test with profile 'a'
# Check that no average daily is found
self.product_1.abc_storage = "a"
self.product_2.abc_storage = "a"
move_1_date = Date.to_string(self.now - relativedelta(weeks=11))
with freeze_time(move_1_date):
move = self._create_move(self.product_1, self.location_bin, 10.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
move_2_date = Date.to_string(self.now - relativedelta(weeks=9))
with freeze_time(move_2_date):
move = self._create_move(self.product_2, self.location_bin_2, 12.0)
move._action_confirm()
move._action_assign()
move.quantity_done = move.product_uom_qty
move._action_done()
self._refresh()
avg_product_1 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_1.id)]
)
self.assertFalse(avg_product_1)
avg_product_2 = self.env["stock.average.daily.sale"].search(
[("product_id", "=", self.product_2.id)]
)
self.assertFalse(avg_product_2)
def test_view_refreshed(self):
self._refresh()
# 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,
)

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="stock_daily_sale_search_view">
<field name="name">stock.daily.sale.search (in stock_average_daily_sale)</field>
<field name="model">stock.average.daily.sale</field>
<field name="arch" type="xml">
<search string="Search Average Daily Sale">
<field name="product_id" />
<field name="warehouse_id" />
<separator />
<filter
string="Can be Sold"
name="filter_to_sell"
domain="[('sale_ok','=',1)]"
/>
<separator />
<filter
string="Normal product"
name="normal_product"
domain="[('is_mto','=',False)]"
/>
<filter
string="On Order"
name="on_order"
domain="[('is_mto','=',True)]"
/>
<group expand='0' string='Group by...'>
<filter
string="Warehouse"
name="group_by_warehouse"
domain="[]"
context="{'group_by': 'warehouse_id'}"
/>
</group>
</search>
</field>
</record>
<record model="ir.ui.view" id="stockdaily_sale_tree_view">
<field name="name">stock.daily.sale.tree (in stock_average_daily_sale)</field>
<field name="model">stock.average.daily.sale</field>
<field name="arch" type="xml">
<tree create="false" delete="false">
<field name="product_id" />
<field name="abc_classification_level" />
<field name="average_qty_by_sale" />
<field name="average_daily_sales_count" />
<field name="average_daily_qty" />
<field name="standard_deviation" />
<field name="daily_standard_deviation" />
<field name="safety" />
<field name="recommended_qty" />
<field name="nbr_sales" />
<field name="qty_in_stock" />
<field name="date_from" />
<field name="date_to" />
<field name="warehouse_id" />
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="stock_average_daily_sale_act_window">
<field name="name">Average Daily Sales</field>
<field name="res_model">stock.average.daily.sale</field>
<field name="view_mode">tree</field>
<field name="domain">[]</field>
<field
name="context"
>{"search_default_filter_to_sell":1, "search_default_normal_product": 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data found.
You maybe need to launch the cron to refresh the average daily sale data.
</p>
</field>
</record>
<record model="ir.ui.menu" id="stock_average_daily_sale_menu">
<field name="name">Average Daily Sales</field>
<field name="parent_id" ref="stock.menu_warehouse_report" />
<field name="action" ref="stock_average_daily_sale_act_window" />
<field name="sequence" eval="99" />
</record>
</odoo>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2021 ACSONE SA/NV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="stock_average_daily_sale_config_tree_view">
<field name="name">stock.average.daily.sale.config.tree</field>
<field name="model">stock.average.daily.sale.config</field>
<field name="arch" type="xml">
<tree>
<field name="warehouse_id" />
<field name="company_id" />
<field name="abc_classification_level" />
<field name="exclude_weekends" />
<field name="period_value" />
<field name="period_name" />
<field name="number_days_qty_in_stock" />
<field name="standard_deviation_exclude_factor" />
<field name="safety_factor" />
</tree>
</field>
</record>
<record model="ir.ui.view" id="stock_average_daily_sale_config_form_view">
<field name="name">stock.average.daily.sale.config.form</field>
<field name="model">stock.average.daily.sale.config</field>
<field name="arch" type="xml">
<form string="Average daily sales computation parameters">
<sheet>
<group>
<group>
<field name="warehouse_id" />
<field name="company_id" />
<field name="abc_classification_level" />
<field name="exclude_weekends" />
</group>
<group>
<field name="period_value" />
<field name="period_name" />
<field name="number_days_qty_in_stock" />
<field name="standard_deviation_exclude_factor" />
<field name="safety_factor" />
</group>
</group>
</sheet>
</form>
</field>
</record>
<record
model="ir.actions.act_window"
id="stock_average_daily_sale_config_act_window"
>
<field name="name">Average daily sales computation parameters</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">stock.average.daily.sale.config</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="stock_average_daily_sale_config_tree_view" />
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="stock_average_daily_sale_config_menu">
<field name="name">Average daily sales computation parameters</field>
<field name="parent_id" ref="stock.menu_product_in_config_stock" />
<field name="action" ref="stock_average_daily_sale_config_act_window" />
<field name="sequence" eval="99" />
</record>
</odoo>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2023 ACSONE SA/NV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="view_warehouse" model="ir.ui.view">
<field name="name">stock.warehouse</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<xpath expr="//group[1]" position="after">
<group string="Average Sales" name="average_sale_reporting">
<field
name="average_daily_sale_root_location_id"
attrs="{'required': [('lot_stock_id', '!=', False)]}"
/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import stock_average_daily_sale_demo

View File

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