Add stock inventory and turnover reports

This commit is contained in:
Cédric Krier
2025-10-11 10:46:03 +02:00
parent da6b14a99b
commit ecc9ffc7de
22 changed files with 1330 additions and 1 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add inventory and turnover reports
Version 7.6.0 - 2025-04-28
--------------------------
+1
View File
@@ -11,3 +11,4 @@ The stock module introduces or extends the following concepts.
.. include:: period.inc.rst
.. include:: configuration.inc.rst
.. include:: product.inc.rst
.. include:: reporting.inc.rst
@@ -0,0 +1,63 @@
.. _model-stock.reporting.inventory:
Inventory Reporting
===================
The *Inventory* report is used to calculate the quantity of `Product
<product:concept-product>` at a given `Location <model-stock.location>` on a
specific date.
.. seealso::
Inventory report can be accessed from the main menu item:
|Inventory & Stock --> Reporting --> Inventory|__
.. |Inventory & Stock --> Reporting --> Inventory| replace:: :menuselection:`Inventory & Stock --> Reporting --> Inventory`
__ https://demo.tryton.org/model/stock.reporting.inventory
.. _model-stock.reporting.inventory.move:
Inventory Moves Reporting
=========================
The *Inventory Moves* report provides information about how `Stock Moves
<model-stock.move>` have affected the stock levels in a `Location
<model-stock.location>` over time.
.. seealso::
Inventory Moves report can be accessed from the :guilabel:`Stock Inventory
Moves` in the product's :guilabel:`Open related records` menu.
.. _model-stock.reporting.inventory.daily:
Inventory Daily Reporting
=========================
The *Inventory Daily* report provides information about how the stock levels of
one or more products have varied over time in a `Location
<model-stock.location>`.
.. seealso::
Inventory Daily report can be accessed from the :guilabel:`Stock Inventory
Daily` in the product's :guilabel:`Open related records` menu.
.. _model-stock.reporting.inventory.turnover:
Inventory Turnover Reporting
============================
The *Inventory Turnover* report calculates the turnover of each `Product
<product:concept-product>` of a `Location <model-stock.location>` over a
period.
.. seealso::
Inventory Turnover report can be accessed from the main menu item:
|Inventory & Stock --> Reporting --> Inventory Turnover|__
.. |Inventory & Stock --> Reporting --> Inventory Turnover| replace:: :menuselection:`Inventory & Stock --> Reporting --> Inventory Turnover`
__ https://demo.tryton.org/model/stock.reporting.inventory.turnover
+8
View File
@@ -443,6 +443,14 @@ class Move(Workflow, ModelSQL, ModelView):
(t.company, Index.Equality()),
(t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['staging', 'draft', 'assigned'])),
Index(
t,
(t.company, Index.Equality()),
(Coalesce(t.effective_date, t.planned_date),
Index.Range()),
(t.state, Index.Equality(cardinality='low')),
(t.product, Index.Range()),
where=t.state.in_(['done', 'assigned', 'draft'])),
})
cls._order[0] = ('id', 'DESC')
cls._transitions |= set((
+1 -1
View File
@@ -71,7 +71,7 @@ this repository contains the full copyright notices and license terms. -->
parent="menu_stock"
sequence="100"
id="menu_reporting"
active="0"/>
active="1"/>
</data>
</tryton>
+526
View File
@@ -0,0 +1,526 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime as dt
from dateutil.relativedelta import relativedelta
from sql import Literal, Null, Window
from sql.aggregate import Min, Sum
from sql.conditionals import Case, Coalesce, Greatest, Least, NullIf
from sql.functions import Function, NthValue, Round
from sql.operators import Concat
from trytond import backend
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.transaction import Transaction
class _SQLite_JulianDay(Function):
__slots__ = ()
_function = 'JULIANDAY'
class _InventoryContextMixin(ModelView):
company = fields.Many2One('company.company', "Company", required=True)
location = fields.Many2One(
'stock.location', "Location", required=True,
domain=[
('type', 'in', ['warehouse', 'storage', 'view']),
])
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_location(cls):
pool = Pool()
Location = pool.get('stock.location')
return Transaction().context.get(
'location', Location.get_default_warehouse())
class _InventoryMixin:
__slots__ = ()
company = fields.Many2One('company.company', "Company")
product = fields.Reference("Product", [
('product.product', "Variant"),
('product.template', "Product"),
])
unit = fields.Function(
fields.Many2One('product.uom', "Unit"),
'on_change_with_unit')
input_quantity = fields.Float("Input Quantity")
output_quantity = fields.Float("Output Quantity")
quantity = fields.Float("Quantity")
@classmethod
def table_query(cls):
pool = Pool()
Location = pool.get('stock.location')
Move = pool.get('stock.move')
Period = pool.get('stock.period')
PeriodCache = pool.get('stock.period.cache')
Product = pool.get('product.product')
transaction = Transaction()
context = transaction.context
move = Move.__table__()
from_location = Location.__table__()
to_location = Location.__table__()
period_cache = PeriodCache.__table__()
product_table = Product.__table__()
if location := context.get('location'):
location = Location(location)
left, right = location.left, location.right
else:
left = right = -1
if date := context.get('date'):
from_date = to_date = date
else:
from_date = context.get('from_date') or dt.date.min
to_date = context.get('to_date') or dt.date.max
company = context.get('company')
if periods := Period.search([
('company', '=', company),
('date', '<=', from_date),
('state', '=', 'closed'),
],
order=[('date', 'DESC')],
limit=1):
period, = periods
else:
period = None
quantities = (
move
.join(from_location,
condition=move.from_location == from_location.id)
.join(to_location,
condition=move.to_location == to_location.id)
)
if context.get('product_type') == 'product.product':
product = Concat('product.product,', move.product)
else:
product = Concat('product.template,', product_table.template)
quantities = (
quantities
.join(product_table,
condition=move.product == product_table.id)
)
move_date = Coalesce(move.effective_date, move.planned_date)
input_clause = (
(to_location.left >= left) & (to_location.right <= right))
output_clause = (
(from_location.left >= left) & (from_location.right <= right))
start_quantity = Sum(
move.quantity * Case((output_clause, -1), else_=1),
filter_=(move_date < from_date))
input_quantity = Sum(
move.quantity,
filter_=input_clause & (move_date >= from_date))
output_quantity = Sum(
move.quantity,
filter_=output_clause & (move_date >= from_date))
date_column = Greatest(move_date, from_date)
if context.get('product_type') == 'product.product':
partition = [move.product]
else:
partition = [product_table.template]
# Use NthValue instead of LastValue to get NULL for the last row
next_date_column = NthValue(
date_column, 2, window=Window(
partition,
order_by=[date_column.asc],
frame='ROWS', start=0, end=1))
state_clause = cls._state_clause(move, to_date)
quantities = quantities.select(
Min(move.id).as_('id'),
product.as_('product'),
date_column.as_('date'),
next_date_column.as_('next_date'),
start_quantity.as_('start_quantity'),
input_quantity.as_('input_quantity'),
output_quantity.as_('output_quantity'),
*cls._quantities_columns(move, product_table),
where=(((input_clause & ~output_clause)
| (output_clause & ~input_clause))
& (move_date <= to_date)
& state_clause
& (move.company == company)),
group_by=[
*partition,
date_column,
*cls._quantities_group_by(move, product_table),
],
)
quantity = Sum(
Coalesce(quantities.start_quantity, 0)
+ Coalesce(quantities.input_quantity, 0)
- Coalesce(quantities.output_quantity, 0),
window=Window(
[quantities.product],
order_by=[*cls._quantities_order_by(quantities)]))
if period:
quantities.where &= move_date > period.date
if context.get('product_type') != 'product.product':
period_cache_product = Product.__table__()
cache = (
period_cache
.join(period_cache_product,
condition=period_cache.product
== period_cache_product.id)
.select(
period_cache_product.template.as_('product'),
Sum(period_cache.internal_quantity
).as_('quantity'),
group_by=[period_cache_product.template]))
else:
cache = (
period_cache
.select(
period_cache.product.as_('product'),
Sum(period_cache.internal_quantity).as_('quantity'),
group_by=[period_cache.product]))
location_cache = Location.__table__()
cache.where = (
(period_cache.period == period.id)
& period_cache.location.in_(
location_cache.select(
location_cache.id,
where=(location_cache.left >= left)
& (location_cache.right <= right))))
query = quantities.join(cache, 'LEFT',
condition=(
cache.product
== cls.product.sql_id(quantities.product, cls)))
quantity += Coalesce(cache.quantity, 0)
else:
query = quantities
return (query
.select(
quantities.id.as_('id'),
Literal(company).as_('company'),
quantities.product.as_('product'),
quantities.input_quantity.as_('input_quantity'),
quantities.output_quantity.as_('output_quantity'),
quantity.as_('quantity'),
*cls._columns(quantities),
where=(
(Coalesce(quantities.next_date, dt.date.max) >= from_date)
| (quantities.date >= from_date))))
@classmethod
def _quantities_columns(cls, move, product):
yield from []
@classmethod
def _quantities_group_by(cls, move, product):
yield from []
@classmethod
def _quantities_order_by(cls, quantities):
yield quantities.date.asc
@classmethod
def _columns(cls, quantities):
yield from []
@classmethod
def _where(cls, quantities):
return Literal(True)
@classmethod
def _state_clause(cls, move, date):
pool = Pool()
Date = pool.get('ir.date')
today = Date.today()
forcast = date > today
move_date = Coalesce(move.effective_date, move.planned_date)
state_clause = (
(move_date <= today) & (move.state == 'done'))
state_clause |= (
((move_date >= today) if forcast else (move_date > today))
& (move.state.in_(['done', 'assigned', 'draft'])))
return state_clause
@fields.depends('product')
def on_change_with_unit(self, name=None):
if self.product:
return self.product.default_uom
class InventoryContext(_InventoryContextMixin):
__name__ = 'stock.reporting.inventory.context'
date = fields.Date("Date", required=True)
product_type = fields.Selection([
('product.product', "Variant"),
('product.template', "Product"),
], "Product Type", required=True)
@classmethod
def default_date(cls):
return Pool().get('ir.date').today()
@classmethod
def default_product_type(cls):
return Transaction().context.get('product_type') or 'product.template'
class Inventory(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory'
class _InventoryRangeContextMixin(_InventoryContextMixin):
from_date = fields.Date(
"From Date", required=True,
domain=[
('from_date', '<=', Eval('to_date')),
])
to_date = fields.Date(
"To Date", required=True,
domain=[
('to_date', '>=', Eval('from_date')),
])
@classmethod
def default_from_date(cls):
pool = Pool()
Date = pool.get('ir.date')
context = Transaction().context
if 'from_date' in context:
return context['from_date']
return Date.today() - relativedelta(day=1, months=6)
@classmethod
def default_to_date(cls):
pool = Pool()
Date = pool.get('ir.date')
context = Transaction().context
if 'to_date' in context:
return context['to_date']
return Date.today() + relativedelta(day=1, months=6)
class InventoryRangeContext(_InventoryRangeContextMixin):
__name__ = 'stock.reporting.inventory.range.context'
class InventoryMove(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.move'
date = fields.Date("Date")
move = fields.Many2One('stock.move', "Move")
origin = fields.Reference("Origin", selection='get_origin')
document = fields.Function(
fields.Reference("Document", selection='get_documents'),
'get_document')
@classmethod
def __setup__(cls):
super().__setup__()
cls._order = [
('date', 'DESC'),
('move', 'DESC NULLS LAST'),
('id', 'DESC'),
]
@classmethod
def _quantities_columns(cls, move, product):
from_date = Transaction().context.get('from_date') or dt.date.min
move_date = Coalesce(move.effective_date, move.planned_date)
yield from super()._quantities_columns(move, product)
yield Case(
(move_date < from_date, Null),
else_=move.id).as_('move')
yield Case(
(move_date < from_date, Null),
else_=move.origin).as_('origin')
@classmethod
def _quantities_order_by(cls, quantities):
yield from super()._quantities_order_by(quantities)
yield quantities.move.asc.nulls_first
@classmethod
def _quantities_group_by(cls, move, product):
from_date = Transaction().context.get('from_date') or dt.date.min
move_date = Coalesce(move.effective_date, move.planned_date)
yield from super()._quantities_group_by(move, product)
yield Case(
(move_date < from_date, Null),
else_=move.id)
yield Case(
(move_date < from_date, Null),
else_=move.origin)
@classmethod
def _columns(cls, quantities):
yield from super()._columns(quantities)
yield quantities.date.as_('date')
yield quantities.move.as_('move')
yield quantities.origin.as_('origin')
@classmethod
def get_origin(cls):
pool = Pool()
Move = pool.get('stock.move')
return Move.get_origin()
@classmethod
def _get_document_models(cls):
pool = Pool()
Move = pool.get('stock.move')
return [m for m, _ in Move.get_shipment() if m]
@classmethod
def get_documents(cls):
pool = Pool()
Model = pool.get('ir.model')
get_name = Model.get_name
models = cls._get_document_models()
return [(None, '')] + [(m, get_name(m)) for m in models]
def get_document(self, name):
if self.move and self.move.shipment:
return str(self.move.shipment)
def get_rec_name(self, name):
name = super().get_rec_name(name)
if self.move:
name = self.move.rec_name
return name
class InventoryDaily(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.daily'
from_date = fields.Date("From Date")
to_date = fields.Date("To Date")
@classmethod
def __setup__(cls):
super().__setup__()
cls._order = [
('from_date', 'DESC'),
('id', 'DESC'),
]
@classmethod
def _quantities_order_by(cls, quantities):
yield from []
yield quantities.date.asc
@classmethod
def _columns(cls, quantities):
transaction = Transaction()
context = transaction.context
to_date = context.get('to_date') or dt.date.max
yield from super()._columns(quantities)
yield quantities.date.as_('from_date')
yield Least(quantities.next_date, to_date).as_('to_date')
class InventoryTurnoverContext(_InventoryRangeContextMixin):
__name__ = 'stock.reporting.inventory.turnover.context'
product_type = fields.Selection([
('product.product', "Variant"),
('product.template', "Product"),
], "Product Type", required=True)
@classmethod
def default_product_type(cls):
return Transaction().context.get('product_type') or 'product.template'
class InventoryTurnover(ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.turnover'
company = fields.Many2One('company.company', "Company")
product = fields.Reference("Product", [
('product.product', "Variant"),
('product.template', "Product"),
])
unit = fields.Function(
fields.Many2One('product.uom', "Unit"),
'on_change_with_unit')
output_quantity = fields.Float("Output Quantity", digits=(None, 3))
average_quantity = fields.Float("Average Quantity", digits=(None, 3))
turnover = fields.Float("Turnover", digits=(None, 3))
@classmethod
def table_query(cls):
pool = Pool()
Inventory = pool.get('stock.reporting.inventory.daily')
transaction = Transaction()
context = transaction.context
inventory = Inventory.__table__()
from_date = context.get('from_date') or dt.date.min
to_date = context.get('to_date') or dt.date.max
company = context.get('company')
days = (to_date - from_date).days + 1
output_quantity = Sum(inventory.output_quantity) / days
inventory_from_date = inventory.from_date
inventory_to_date = inventory.to_date
if backend.name == 'sqlite':
inventory_from_date = _SQLite_JulianDay(inventory_from_date)
inventory_to_date = _SQLite_JulianDay(inventory_to_date)
average_quantity = (
Sum(Case(
(inventory.quantity >= 0, inventory.quantity),
else_=0)
* (inventory_to_date - inventory_from_date
+ Case((inventory.to_date == to_date, 1), else_=0)))
/ days)
def round_sql(expression, digits=2):
factor = 10 ** digits
return Round(expression * factor) / factor
return (inventory
.select(
cls.product.sql_id(inventory.product, cls).as_('id'),
Literal(company).as_('company'),
inventory.product.as_('product'),
round_sql(
output_quantity,
cls.output_quantity.digits[1]).as_('output_quantity'),
round_sql(
average_quantity,
cls.average_quantity.digits[1]).as_('average_quantity'),
round_sql(
output_quantity / NullIf(average_quantity, 0),
cls.turnover.digits[1]).as_('turnover'),
group_by=[inventory.product]))
@fields.depends('product')
def on_change_with_unit(self, name=None):
if self.product:
return self.product.default_uom
+315
View File
@@ -0,0 +1,315 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="reporting_inventory_context_view_form">
<field name="model">stock.reporting.inventory.context</field>
<field name="type">form</field>
<field name="name">reporting_inventory_context_form</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_view_list">
<field name="model">stock.reporting.inventory</field>
<field name="type">tree</field>
<field name="name">reporting_inventory_list</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_view_form">
<field name="model">stock.reporting.inventory</field>
<field name="type">form</field>
<field name="name">reporting_inventory_form</field>
</record>
<record model="ir.action.act_window" id="act_reporting_inventory">
<field name="name">Inventory</field>
<field name="res_model">stock.reporting.inventory</field>
<field name="context_model">stock.reporting.inventory.context</field>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="reporting_inventory_view_list"/>
<field name="act_window" ref="act_reporting_inventory"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="reporting_inventory_view_form"/>
<field name="act_window" ref="act_reporting_inventory"/>
</record>
<menuitem
parent="menu_reporting"
action="act_reporting_inventory"
sequence="20"
id="menu_reporting_inventory"/>
<record model="ir.rule.group" id="rule_group_reporting_inventory_companies">
<field name="name">User in companies</field>
<field name="model">stock.reporting.inventory</field>
<field name="global_p" eval="True"/>
</record>
<record model="ir.rule" id="rule_reporting_inventory_companies">
<field
name="domain"
eval="[('company', 'in', Eval('companies', []))]"
pyson="1"/>
<field name="rule_group" ref="rule_group_reporting_inventory_companies"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory">
<field name="model">stock.reporting.inventory</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_group_stock">
<field name="model">stock.reporting.inventory</field>
<field name="group" ref="group_stock"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.ui.view" id="reporting_inventory_range_context_view_form">
<field name="model">stock.reporting.inventory.range.context</field>
<field name="type">form</field>
<field name="name">reporting_inventory_range_context_form</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_move_view_list">
<field name="model">stock.reporting.inventory.move</field>
<field name="type">tree</field>
<field name="name">reporting_inventory_move_list</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_move_view_form">
<field name="model">stock.reporting.inventory.move</field>
<field name="type">form</field>
<field name="name">reporting_inventory_move_form</field>
</record>
<record model="ir.action.act_window" id="act_reporting_inventory_move">
<field name="name">Stock Inventory Moves</field>
<field name="res_model">stock.reporting.inventory.move</field>
<field name="context_model">stock.reporting.inventory.range.context</field>
<field
name="domain"
eval="[('product', '=', (Eval('active_model', 'product.template'), Eval('active_id', -1)))]"
pyson="1"/>
<field
name="context"
eval="{'product_type': Eval('active_model', 'product.template'), 'date': None}"
pyson="1"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_move_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="reporting_inventory_move_view_list"/>
<field name="act_window" ref="act_reporting_inventory_move"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_move_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="reporting_inventory_move_view_form"/>
<field name="act_window" ref="act_reporting_inventory_move"/>
</record>
<record model="ir.action.keyword" id="act_reporting_inventory_move_keyword_product">
<field name="keyword">form_relate</field>
<field name="model">product.product,-1</field>
<field name="action" ref="act_reporting_inventory_move"/>
</record>
<record model="ir.action.keyword" id="act_reporting_inventory_move_keyword_template">
<field name="keyword">form_relate</field>
<field name="model">product.template,-1</field>
<field name="action" ref="act_reporting_inventory_move"/>
</record>
<record model="ir.rule.group" id="rule_group_reporting_inventory_move_companies">
<field name="name">User in companies</field>
<field name="model">stock.reporting.inventory.move</field>
<field name="global_p" eval="True"/>
</record>
<record model="ir.rule" id="rule_reporting_inventory_move_companies">
<field
name="domain"
eval="[('company', 'in', Eval('companies', []))]"
pyson="1"/>
<field name="rule_group" ref="rule_group_reporting_inventory_move_companies"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_move">
<field name="model">stock.reporting.inventory.move</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_move_group_stock">
<field name="model">stock.reporting.inventory.move</field>
<field name="group" ref="group_stock"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.ui.view" id="reporting_inventory_daily_view_list">
<field name="model">stock.reporting.inventory.daily</field>
<field name="type">tree</field>
<field name="name">reporting_inventory_daily_list</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_daily_view_form">
<field name="model">stock.reporting.inventory.daily</field>
<field name="type">form</field>
<field name="name">reporting_inventory_daily_form</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_daily_view_graph">
<field name="model">stock.reporting.inventory.daily</field>
<field name="type">graph</field>
<field name="name">reporting_inventory_daily_graph</field>
</record>
<record model="ir.action.act_window" id="act_reporting_inventory_daily">
<field name="name">Stock Inventory Daily</field>
<field name="res_model">stock.reporting.inventory.daily</field>
<field name="context_model">stock.reporting.inventory.range.context</field>
<field
name="domain"
eval="[('product', '=', (Eval('active_model', 'product.template'), Eval('active_id', -1)))]"
pyson="1"/>
<field
name="context"
eval="{'product_type': Eval('active_model', 'product.template'), 'date': None}"
pyson="1"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_daily_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="reporting_inventory_daily_view_list"/>
<field name="act_window" ref="act_reporting_inventory_daily"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_daily_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="reporting_inventory_daily_view_graph"/>
<field name="act_window" ref="act_reporting_inventory_daily"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_daily_view3">
<field name="sequence" eval="30"/>
<field name="view" ref="reporting_inventory_daily_view_form"/>
<field name="act_window" ref="act_reporting_inventory_daily"/>
</record>
<record model="ir.action.keyword" id="act_reporting_inventory_daily_keyword_product">
<field name="keyword">form_relate</field>
<field name="model">product.product,-1</field>
<field name="action" ref="act_reporting_inventory_daily"/>
</record>
<record model="ir.action.keyword" id="act_reporting_inventory_daily_keyword_template">
<field name="keyword">form_relate</field>
<field name="model">product.template,-1</field>
<field name="action" ref="act_reporting_inventory_daily"/>
</record>
<record model="ir.rule.group" id="rule_group_reporting_inventory_daily_companies">
<field name="name">User in companies</field>
<field name="model">stock.reporting.inventory.daily</field>
<field name="global_p" eval="True"/>
</record>
<record model="ir.rule" id="rule_reporting_inventory_daily_companies">
<field
name="domain"
eval="[('company', 'in', Eval('companies', []))]"
pyson="1"/>
<field name="rule_group" ref="rule_group_reporting_inventory_daily_companies"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_daily">
<field name="model">stock.reporting.inventory.daily</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_daily_group_stock">
<field name="model">stock.reporting.inventory.daily</field>
<field name="group" ref="group_stock"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.ui.view" id="reporting_inventory_turnover_context_view_form">
<field name="model">stock.reporting.inventory.turnover.context</field>
<field name="type">form</field>
<field name="name">reporting_inventory_turnover_context_form</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_turnover_view_list">
<field name="model">stock.reporting.inventory.turnover</field>
<field name="type">tree</field>
<field name="name">reporting_inventory_turnover_list</field>
</record>
<record model="ir.ui.view" id="reporting_inventory_turnover_view_form">
<field name="model">stock.reporting.inventory.turnover</field>
<field name="type">form</field>
<field name="name">reporting_inventory_turnover_form</field>
</record>
<record model="ir.action.act_window" id="act_reporting_inventory_turnover">
<field name="name">Inventory Turnover</field>
<field name="res_model">stock.reporting.inventory.turnover</field>
<field name="context_model">stock.reporting.inventory.turnover.context</field>
<field
name="context"
eval="{'from_date': Date(delta_years=-1), 'to_date': Date()}"
pyson="1"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_turnover_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="reporting_inventory_turnover_view_list"/>
<field name="act_window" ref="act_reporting_inventory_turnover"/>
</record>
<record model="ir.action.act_window.view" id="act_reporting_inventory_turnover_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="reporting_inventory_turnover_view_form"/>
<field name="act_window" ref="act_reporting_inventory_turnover"/>
</record>
<menuitem
parent="menu_reporting"
action="act_reporting_inventory_turnover"
sequence="30"
id="menu_reporting_inventory_turnover"/>
<record model="ir.rule.group" id="rule_group_reporting_inventory_turnover_companies">
<field name="name">User in companies</field>
<field name="model">stock.reporting.inventory.turnover</field>
<field name="global_p" eval="True"/>
</record>
<record model="ir.rule" id="rule_reporting_inventory_turnover_companies">
<field
name="domain"
eval="[('company', 'in', Eval('companies', []))]"
pyson="1"/>
<field name="rule_group" ref="rule_group_reporting_inventory_turnover_companies"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_turnover">
<field name="model">stock.reporting.inventory.turnover</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_reporting_inventory_turnover_group_stock">
<field name="model">stock.reporting.inventory.turnover</field>
<field name="group" ref="group_stock"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
</data>
</tryton>
@@ -0,0 +1,6 @@
[
{"period_close": false, "product_type": "product.template"}
,{"period_close": false, "product_type": "product.product"}
,{"period_close": true, "product_type": "product.template"}
,{"period_close": true, "product_type": "product.product"}
]
@@ -0,0 +1,239 @@
==================================
Stock Reporting Inventory Scenario
==================================
Imports::
>>> import datetime as dt
>>> from decimal import Decimal
>>> from unittest.mock import patch
>>> from proteus import Model
>>> from trytond.ir.date import Date
>>> from trytond.modules.company.tests.tools import create_company
>>> from trytond.modules.currency.tests.tools import get_currency
>>> from trytond.tests.tools import activate_modules, assertEqual
>>> period_close = globals().get('period_close', False)
>>> product_type = globals().get('product_type', 'product.template')
Patch today::
>>> mock = patch.object(Date, 'today', return_value=dt.date(2025, 1, 15))
>>> _ = mock.start()
Activate modules::
>>> config = activate_modules('stock', create_company)
>>> Inventory = Model.get('stock.reporting.inventory')
>>> InventoryMove = Model.get('stock.reporting.inventory.move')
>>> InventoryDaily = Model.get('stock.reporting.inventory.daily')
>>> InventoryTurnover = Model.get('stock.reporting.inventory.turnover')
>>> Location = Model.get('stock.location')
>>> Move = Model.get('stock.move')
>>> Period = Model.get('stock.period')
>>> ProductTemplate = Model.get('product.template')
>>> ProductUom = Model.get('product.uom')
Get currency::
>>> currency = get_currency()
Create product::
>>> unit, = ProductUom.find([('name', '=', 'Unit')])
>>> template = ProductTemplate()
>>> template.name = 'Product'
>>> template.default_uom = unit
>>> template.type = 'goods'
>>> template.save()
>>> product, = template.products
Get stock locations::
>>> warehouse_loc, = Location.find([('code', '=', 'WH')])
>>> supplier_loc, = Location.find([('code', '=', 'SUP')])
>>> customer_loc, = Location.find([('code', '=', 'CUS')])
>>> storage_loc, = Location.find([('code', '=', 'STO')])
Fill warehouse::
>>> move = Move()
>>> move.product = product
>>> move.from_location = supplier_loc
>>> move.to_location = storage_loc
>>> move.quantity = 10
>>> move.effective_date = dt.date(2025, 1, 1)
>>> move.unit_price = Decimal('10.0000')
>>> move.currency = currency
>>> move.click('do')
>>> move.state
'done'
>>> move = Move()
>>> move.product = product
>>> move.from_location = storage_loc
>>> move.to_location = customer_loc
>>> move.unit = unit
>>> move.quantity = 1
>>> move.effective_date = dt.date(2025, 1, 1)
>>> move.unit_price = Decimal('20.0000')
>>> move.currency = currency
>>> move.click('do')
>>> move.state
'done'
>>> move = Move()
>>> move.product = product
>>> move.from_location = storage_loc
>>> move.to_location = customer_loc
>>> move.unit = unit
>>> move.quantity = 2
>>> move.effective_date = dt.date(2025, 1, 10)
>>> move.unit_price = Decimal('20.0000')
>>> move.currency = currency
>>> move.click('do')
>>> move.state
'done'
Forecast some moves::
>>> move = Move()
>>> move.product = product
>>> move.from_location = supplier_loc
>>> move.to_location = storage_loc
>>> move.quantity = 10
>>> move.effective_date = dt.date(2025, 1, 5)
>>> move.unit_price = Decimal('10.0000')
>>> move.currency = currency
>>> move.save()
>>> move.state
'draft'
>>> move = Move()
>>> move.product = product
>>> move.from_location = storage_loc
>>> move.to_location = customer_loc
>>> move.unit = unit
>>> move.quantity = 3
>>> move.planned_date = dt.date(2025, 1, 20)
>>> move.unit_price = Decimal('20.0000')
>>> move.currency = currency
>>> move.save()
>>> move.state
'draft'
Close period::
>>> period = Period(date=dt.date(2025, 1, 5))
>>> if period_close:
... period.click('close')
Check inventory::
>>> with config.set_context(
... location=warehouse_loc.id,
... product_type=product_type,
... date=dt.date(2024, 1, 1)):
... Inventory.find([])
[]
>>> with config.set_context(
... location=warehouse_loc.id,
... product_type=product_type,
... date=dt.date(2025, 1, 1)):
... inventory, = Inventory.find([])
>>> inventory.quantity
9.0
>>> assertEqual(inventory.product.__class__.__name__, product_type)
>>> with config.set_context(
... location=warehouse_loc.id,
... product_type=product_type,
... date=dt.date(2025, 1, 15)):
... inventory, = Inventory.find([])
>>> inventory.quantity
7.0
>>> with config.set_context(
... location=warehouse_loc.id,
... product_type=product_type,
... date=dt.date(2025, 1, 20)):
... inventory, = Inventory.find([])
>>> inventory.quantity
4.0
>>> with config.set_context(
... location=warehouse_loc.id,
... product_type=product_type):
... inventory_moves = InventoryMove.find([])
... inventories_daily = InventoryDaily.find([])
>>> [i.quantity for i in inventory_moves]
[4.0, 7.0, 9.0, 10.0]
>>> [(i.input_quantity, i.output_quantity) for i in inventory_moves]
[(None, 3.0), (None, 2.0), (None, 1.0), (10.0, None)]
>>> [i.quantity for i in inventories_daily]
[4.0, 7.0, 9.0]
>>> [(i.input_quantity, i.output_quantity) for i in inventories_daily]
[(None, 3.0), (None, 2.0), (10.0, 1.0)]
>>> with config.set_context(
... location=warehouse_loc.id,
... from_date=dt.date(2025, 1, 15),
... to_date=dt.date(2025, 1, 29),
... product_type=product_type):
... inventory_moves = InventoryMove.find([])
... inventories_daily = InventoryDaily.find([])
>>> [i.quantity for i in inventory_moves]
[4.0, 7.0]
>>> [(i.input_quantity, i.output_quantity) for i in inventory_moves]
[(None, 3.0), (None, None)]
>>> [i.quantity for i in inventories_daily]
[4.0, 7.0]
>>> [(i.input_quantity, i.output_quantity) for i in inventories_daily]
[(None, 3.0), (None, None)]
>>> with config.set_context(
... location=warehouse_loc.id,
... from_date=dt.date(2025, 1, 10),
... to_date=dt.date(2025, 1, 29),
... product_type=product_type):
... inventory_moves = InventoryMove.find([])
... inventories_daily = InventoryDaily.find([])
>>> assertEqual(
... [i.quantity for i in inventory_moves],
... [4.0, 7.0] if period_close else [4.0, 7.0, 9.0])
>>> assertEqual(
... [(i.input_quantity, i.output_quantity) for i in inventory_moves],
... [(None, 3.0), (None, 2.0)] if period_close
... else [(None, 3.0), (None, 2.0), (None, None)])
>>> [i.quantity for i in inventories_daily]
[4.0, 7.0]
>>> [(i.input_quantity, i.output_quantity) for i in inventories_daily]
[(None, 3.0), (None, 2.0)]
Check Inventory turnover::
>>> with config.set_context(
... location=warehouse_loc.id,
... from_date=dt.date(2025, 1, 10),
... to_date=dt.date(2025, 1, 29),
... product_type=product_type):
... turnover, = InventoryTurnover.find([])
>>> turnover.output_quantity
0.25
>>> turnover.average_quantity
5.5
>>> turnover.turnover
0.045
>>> assertEqual(turnover.product.__class__.__name__, product_type)
+8
View File
@@ -19,6 +19,7 @@ xml:
period.xml
message.xml
res.xml
stock_reporting.xml
stock_reporting_margin.xml
[register]
@@ -60,6 +61,13 @@ model:
inventory.InventoryLine
inventory.CountSearch
inventory.CountQuantity
stock_reporting.InventoryContext
stock_reporting.Inventory
stock_reporting.InventoryRangeContext
stock_reporting.InventoryMove
stock_reporting.InventoryDaily
stock_reporting.InventoryTurnoverContext
stock_reporting.InventoryTurnover
stock_reporting_margin.Context
stock_reporting_margin.Main
stock_reporting_margin.MainTimeseries
@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="date"/>
<field name="date"/>
<label name="product_type"/>
<field name="product_type"/>
<label name="location"/>
<field name="location"/>
<label name="company"/>
<field name="company"/>
</form>
@@ -0,0 +1,20 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="product"/>
<field name="product" colspan="3"/>
<label name="from_date"/>
<field name="from_date"/>
<label name="to_date"/>
<field name="to_date"/>
<label name="input_quantity"/>
<field name="input_quantity" symbol="unit"/>
<label name="output_quantity"/>
<field name="output_quantity" symbol="unit"/>
<label name="quantity"/>
<field name="quantity" symbol="unit"/>
</form>
@@ -0,0 +1,11 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<graph type="line" legend="0">
<x>
<field name="from_date"/>
</x>
<y>
<field name="quantity" empty="0" interpolation="constant-left"/>
</y>
</graph>
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="product" expand="2"/>
<field name="from_date" string="Date"/>
<field name="input_quantity" symbol="unit" optional="1"/>
<field name="output_quantity" symbol="unit" optional="1"/>
<field name="quantity" symbol="unit"/>
</tree>
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="product"/>
<field name="product" colspan="3"/>
<label name="quantity"/>
<field name="quantity" symbol="unit"/>
</form>
@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="product" expand="2"/>
<field name="quantity" symbol="unit"/>
</tree>
@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="product"/>
<field name="product" colspan="3"/>
<label name="date"/>
<field name="date"/>
<label name="move"/>
<field name="move"/>
<label name="origin"/>
<field name="origin"/>
<label name="document"/>
<field name="document"/>
<label name="input_quantity"/>
<field name="input_quantity" symbol="unit"/>
<label name="output_quantity"/>
<field name="output_quantity" symbol="unit"/>
<label name="quantity"/>
<field name="quantity" symbol="unit"/>
</form>
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="product" expand="2"/>
<field name="date"/>
<field name="origin" expand="1" optional="1"/>
<field name="document" expand="1" optional="0"/>
<field name="input_quantity" symbol="unit" optional="1"/>
<field name="output_quantity" symbol="unit" optional="1"/>
<field name="quantity" symbol="unit"/>
</tree>
@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="from_date"/>
<field name="from_date"/>
<label name="to_date"/>
<field name="to_date"/>
<label name="location"/>
<field name="location"/>
<label name="company"/>
<field name="company"/>
</form>
@@ -0,0 +1,15 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form col="6">
<label name="from_date"/>
<field name="from_date"/>
<label name="to_date"/>
<field name="to_date"/>
<label name="product_type"/>
<field name="product_type"/>
<label name="location"/>
<field name="location" colspan="3"/>
<label name="company"/>
<field name="company"/>
</form>
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="product"/>
<field name="product" colspan="3"/>
<label name="output_quantity"/>
<field name="output_quantity" symbol="unit"/>
<label name="average_quantity"/>
<field name="average_quantity" symbol="unit"/>
<label name="turnover"/>
<field name="turnover"/>
</form>
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="product" expand="2"/>
<field name="output_quantity" symbol="unit" optional="0"/>
<field name="average_quantity" symbol="unit" optional="0"/>
<field name="turnover"/>
</tree>