From ecc9ffc7dead46170e10e6231a16ad0cf9230cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 11 Oct 2025 10:46:03 +0200 Subject: [PATCH] Add stock inventory and turnover reports --- modules/stock/CHANGELOG | 1 + modules/stock/doc/design/index.rst | 1 + modules/stock/doc/design/reporting.inc.rst | 63 +++ modules/stock/move.py | 8 + modules/stock/stock.xml | 2 +- modules/stock/stock_reporting.py | 526 ++++++++++++++++++ modules/stock/stock_reporting.xml | 315 +++++++++++ .../scenario_stock_reporting_inventory.json | 6 + .../scenario_stock_reporting_inventory.rst | 239 ++++++++ modules/stock/tryton.cfg | 8 + .../view/reporting_inventory_context_form.xml | 14 + .../view/reporting_inventory_daily_form.xml | 20 + .../view/reporting_inventory_daily_graph.xml | 11 + .../view/reporting_inventory_daily_list.xml | 10 + .../stock/view/reporting_inventory_form.xml | 10 + .../stock/view/reporting_inventory_list.xml | 7 + .../view/reporting_inventory_move_form.xml | 25 + .../view/reporting_inventory_move_list.xml | 12 + ...reporting_inventory_range_context_form.xml | 14 + ...orting_inventory_turnover_context_form.xml | 15 + .../reporting_inventory_turnover_form.xml | 15 + .../reporting_inventory_turnover_list.xml | 9 + 22 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 modules/stock/doc/design/reporting.inc.rst create mode 100644 modules/stock/stock_reporting.py create mode 100644 modules/stock/stock_reporting.xml create mode 100644 modules/stock/tests/scenario_stock_reporting_inventory.json create mode 100644 modules/stock/tests/scenario_stock_reporting_inventory.rst create mode 100644 modules/stock/view/reporting_inventory_context_form.xml create mode 100644 modules/stock/view/reporting_inventory_daily_form.xml create mode 100644 modules/stock/view/reporting_inventory_daily_graph.xml create mode 100644 modules/stock/view/reporting_inventory_daily_list.xml create mode 100644 modules/stock/view/reporting_inventory_form.xml create mode 100644 modules/stock/view/reporting_inventory_list.xml create mode 100644 modules/stock/view/reporting_inventory_move_form.xml create mode 100644 modules/stock/view/reporting_inventory_move_list.xml create mode 100644 modules/stock/view/reporting_inventory_range_context_form.xml create mode 100644 modules/stock/view/reporting_inventory_turnover_context_form.xml create mode 100644 modules/stock/view/reporting_inventory_turnover_form.xml create mode 100644 modules/stock/view/reporting_inventory_turnover_list.xml diff --git a/modules/stock/CHANGELOG b/modules/stock/CHANGELOG index 1855f8e1e4..86d894a37a 100644 --- a/modules/stock/CHANGELOG +++ b/modules/stock/CHANGELOG @@ -1,3 +1,4 @@ +* Add inventory and turnover reports Version 7.6.0 - 2025-04-28 -------------------------- diff --git a/modules/stock/doc/design/index.rst b/modules/stock/doc/design/index.rst index 7f28cfdd7a..d072b60d54 100644 --- a/modules/stock/doc/design/index.rst +++ b/modules/stock/doc/design/index.rst @@ -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 diff --git a/modules/stock/doc/design/reporting.inc.rst b/modules/stock/doc/design/reporting.inc.rst new file mode 100644 index 0000000000..858daba3f6 --- /dev/null +++ b/modules/stock/doc/design/reporting.inc.rst @@ -0,0 +1,63 @@ +.. _model-stock.reporting.inventory: + +Inventory Reporting +=================== + +The *Inventory* report is used to calculate the quantity of `Product +` at a given `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 +` have affected the stock levels in a `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 +`. + +.. 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 +` of a `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 diff --git a/modules/stock/move.py b/modules/stock/move.py index e8617e54f0..4418eb27b5 100644 --- a/modules/stock/move.py +++ b/modules/stock/move.py @@ -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(( diff --git a/modules/stock/stock.xml b/modules/stock/stock.xml index 6fbe3d1729..cef73475ff 100644 --- a/modules/stock/stock.xml +++ b/modules/stock/stock.xml @@ -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"/> diff --git a/modules/stock/stock_reporting.py b/modules/stock/stock_reporting.py new file mode 100644 index 0000000000..7410141913 --- /dev/null +++ b/modules/stock/stock_reporting.py @@ -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 diff --git a/modules/stock/stock_reporting.xml b/modules/stock/stock_reporting.xml new file mode 100644 index 0000000000..a59ff566e9 --- /dev/null +++ b/modules/stock/stock_reporting.xml @@ -0,0 +1,315 @@ + + + + + + stock.reporting.inventory.context + form + reporting_inventory_context_form + + + + stock.reporting.inventory + tree + reporting_inventory_list + + + + stock.reporting.inventory + form + reporting_inventory_form + + + + Inventory + stock.reporting.inventory + stock.reporting.inventory.context + + + + + + + + + + + + + + + + User in companies + stock.reporting.inventory + + + + + + + + + stock.reporting.inventory + + + + + + + stock.reporting.inventory + + + + + + + + + stock.reporting.inventory.range.context + form + reporting_inventory_range_context_form + + + + stock.reporting.inventory.move + tree + reporting_inventory_move_list + + + + stock.reporting.inventory.move + form + reporting_inventory_move_form + + + + Stock Inventory Moves + stock.reporting.inventory.move + stock.reporting.inventory.range.context + + + + + + + + + + + + + + + form_relate + product.product,-1 + + + + form_relate + product.template,-1 + + + + + User in companies + stock.reporting.inventory.move + + + + + + + + + stock.reporting.inventory.move + + + + + + + stock.reporting.inventory.move + + + + + + + + + stock.reporting.inventory.daily + tree + reporting_inventory_daily_list + + + + stock.reporting.inventory.daily + form + reporting_inventory_daily_form + + + + stock.reporting.inventory.daily + graph + reporting_inventory_daily_graph + + + + Stock Inventory Daily + stock.reporting.inventory.daily + stock.reporting.inventory.range.context + + + + + + + + + + + + + + + + + + + + form_relate + product.product,-1 + + + + form_relate + product.template,-1 + + + + + User in companies + stock.reporting.inventory.daily + + + + + + + + + stock.reporting.inventory.daily + + + + + + + stock.reporting.inventory.daily + + + + + + + + + stock.reporting.inventory.turnover.context + form + reporting_inventory_turnover_context_form + + + + stock.reporting.inventory.turnover + tree + reporting_inventory_turnover_list + + + + stock.reporting.inventory.turnover + form + reporting_inventory_turnover_form + + + + Inventory Turnover + stock.reporting.inventory.turnover + stock.reporting.inventory.turnover.context + + + + + + + + + + + + + + + + + User in companies + stock.reporting.inventory.turnover + + + + + + + + + stock.reporting.inventory.turnover + + + + + + + stock.reporting.inventory.turnover + + + + + + + + diff --git a/modules/stock/tests/scenario_stock_reporting_inventory.json b/modules/stock/tests/scenario_stock_reporting_inventory.json new file mode 100644 index 0000000000..4f1142f424 --- /dev/null +++ b/modules/stock/tests/scenario_stock_reporting_inventory.json @@ -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"} +] diff --git a/modules/stock/tests/scenario_stock_reporting_inventory.rst b/modules/stock/tests/scenario_stock_reporting_inventory.rst new file mode 100644 index 0000000000..68942965f0 --- /dev/null +++ b/modules/stock/tests/scenario_stock_reporting_inventory.rst @@ -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) diff --git a/modules/stock/tryton.cfg b/modules/stock/tryton.cfg index ff115ad24d..ad8d624b09 100644 --- a/modules/stock/tryton.cfg +++ b/modules/stock/tryton.cfg @@ -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 diff --git a/modules/stock/view/reporting_inventory_context_form.xml b/modules/stock/view/reporting_inventory_context_form.xml new file mode 100644 index 0000000000..3efabe3068 --- /dev/null +++ b/modules/stock/view/reporting_inventory_context_form.xml @@ -0,0 +1,14 @@ + + +
+