details:   https://code.tryton.org/tryton/commit/06643f7ecb59
branch:    default
user:      Maxime Richez <[email protected]>
date:      Thu Sep 03 00:02:28 2026 +0200
description:
        Calculate variation and check tolerance on inventory quantity and cost

        Closes #14688
diffstat:

 modules/stock/CHANGELOG                                    |    2 +
 modules/stock/configuration.py                             |   51 +++++
 modules/stock/exceptions.py                                |    4 +
 modules/stock/inventory.py                                 |  114 +++++++++++-
 modules/stock/message.xml                                  |    4 +
 modules/stock/tests/scenario_stock_inventory_tolerance.rst |  130 +++++++++++++
 modules/stock/tryton.cfg                                   |    1 +
 modules/stock/view/configuration_form.xml                  |   10 +
 modules/stock/view/inventory_line_form.xml                 |    5 +
 modules/stock/view/inventory_line_list.xml                 |    4 +
 10 files changed, 323 insertions(+), 2 deletions(-)

diffs (486 lines):

diff -r 020452568c78 -r 06643f7ecb59 modules/stock/CHANGELOG
--- a/modules/stock/CHANGELOG   Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/CHANGELOG   Thu Sep 03 00:02:28 2026 +0200
@@ -1,3 +1,5 @@
+* Check tolerance on quantity and cost variation of inventory line
+* Calculate quantity and cost variation of inventory line
 * Add scheduled task to close period automatically
 * Add scheduled task to create period automatically
 
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/configuration.py
--- a/modules/stock/configuration.py    Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/configuration.py    Thu Sep 03 00:02:28 2026 +0200
@@ -18,6 +18,16 @@
         ],
     help="The default location used for stock that is in transit between "
     "warehouses.")
+inventory_quantity_tolerance = fields.Float(
+    "Inventory Quantity Tolerance", digits=(None, 4),
+    help="The inventory quantity variation accepted in percentage.")
+inventory_cost_thresold = fields.Numeric(
+    "Inventory Cost Thresold",
+    domain=['OR',
+        ('inventory_cost_thresold', '=', None),
+        ('inventory_cost_thresold', '>', 0),
+        ],
+    help="The inventory cost thresold accepted in company currency.")
 
 
 def default_func(field_name):
@@ -103,6 +113,21 @@
                 ],
             help="Used to generate the number given to inventories."))
     shipment_internal_transit = fields.MultiValue(shipment_internal_transit)
+    inventory_quantity_tolerance = fields.MultiValue(
+        inventory_quantity_tolerance)
+    inventory_cost_thresold = fields.MultiValue(
+        inventory_cost_thresold)
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls.inventory_quantity_tolerance.domain = [
+            'OR',
+            ('inventory_quantity_tolerance', '=', None),
+            [
+                ('inventory_quantity_tolerance', '>=', 0),
+                ('inventory_quantity_tolerance', '<=', 1),
+                ]]
 
     period_creation_interval = fields.MultiValue(
         fields.TimeDelta(
@@ -132,6 +157,11 @@
             return pool.get('stock.configuration.location')
         if field in {'period_creation_interval', 'period_closing_delay'}:
             return pool.get('stock.configuration.period')
+        if field in {
+                'inventory_quantity_tolerance',
+                'inventory_cost_thresold',
+                }:
+            return pool.get('stock.configuration.inventory')
         return super().multivalue_model(field)
 
     default_shipment_in_sequence = default_func('shipment_in_sequence')
@@ -145,6 +175,10 @@
     default_inventory_sequence = default_func('inventory_sequence')
     default_shipment_internal_transit = default_func(
         'shipment_internal_transit')
+    default_inventory_quantity_tolerance = default_func(
+        'inventory_quantity_tolerance')
+    default_inventory_cost_thresold = default_func(
+        'inventory_cost_thresold')
 
 
 class ConfigurationSequence(ModelSQL, CompanyValueMixin):
@@ -232,3 +266,20 @@
             ('period_closing_delay', '=', None),
             ('period_closing_delay', '>=', TimeDelta()),
             ])
+
+
+class ConfigurationInventory(ModelSQL, ValueMixin):
+    __name__ = 'stock.configuration.inventory'
+    inventory_quantity_tolerance = inventory_quantity_tolerance
+    inventory_cost_thresold = inventory_cost_thresold
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls.inventory_quantity_tolerance.domain = ['OR',
+            ('inventory_quantity_tolerance', '=', None),
+            [
+                ('inventory_quantity_tolerance', '>=', 0),
+                ('inventory_quantity_tolerance', '<=', 1),
+                ],
+            ]
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/exceptions.py
--- a/modules/stock/exceptions.py       Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/exceptions.py       Thu Sep 03 00:02:28 2026 +0200
@@ -28,6 +28,10 @@
     pass
 
 
+class InventoryOverToleranceWarning(UserWarning):
+    pass
+
+
 class MoveOriginWarning(UserWarning):
     pass
 
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/inventory.py
--- a/modules/stock/inventory.py        Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/inventory.py        Thu Sep 03 00:02:28 2026 +0200
@@ -1,21 +1,25 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
 from collections import defaultdict
+from decimal import Decimal
 
 from sql import Null
 from sql.functions import CharLength
 
-from trytond.i18n import gettext
+from trytond.i18n import gettext, ngettext
 from trytond.model import (
     ChatMixin, Check, Index, Model, ModelSQL, ModelView, Workflow, fields)
 from trytond.model.exceptions import AccessError
+from trytond.modules.product import price_digits, round_price
 from trytond.pool import Pool
 from trytond.pyson import Bool, Eval, If
 from trytond.tools import is_full_text, lstrip_wildcard
 from trytond.transaction import Transaction
 from trytond.wizard import Button, StateTransition, StateView, Wizard
 
-from .exceptions import InventoryCountWarning, InventoryValidationError
+from .exceptions import (
+    InventoryCountWarning, InventoryOverToleranceWarning,
+    InventoryValidationError)
 
 
 class Inventory(Workflow, ModelSQL, ModelView, ChatMixin):
@@ -118,6 +122,29 @@
     def default_company():
         return Transaction().context.get('company')
 
+    def check_tolerance(self):
+        pool = Pool()
+        Warning = pool.get('res.user.warning')
+
+        over_tolerance_lines = [
+            l for l in self.lines
+            if (l.tolerance_quantity_variation
+                and l.tolerance_quantity_variation > 1)
+            or (l.tolerance_cost_variation
+                and l.tolerance_cost_variation > 1)]
+        if over_tolerance_lines:
+            names = ', '.join(l.rec_name for l in over_tolerance_lines[:5])
+            if len(over_tolerance_lines) > 5:
+                names += '...'
+            warning_key = Warning.format(
+                'inventory_over_tolerance', over_tolerance_lines)
+            if Warning.check(warning_key):
+                raise InventoryOverToleranceWarning(
+                    warning_key,
+                    ngettext('stock.msg_inventory_over_tolerance',
+                        len(over_tolerance_lines),
+                        lines=names))
+
     def get_rec_name(self, name):
         pool = Pool()
         Lang = pool.get('ir.lang')
@@ -155,6 +182,7 @@
         transaction = Transaction()
         moves = []
         for inventory in inventories:
+            inventory.check_tolerance()
             keys = set()
             for line in inventory.lines:
                 key = line.unique_key
@@ -340,6 +368,20 @@
                 ()),
             ],
         help="The actual quantity found in the location.")
+    quantity_variation = fields.Function(
+        fields.Float("Quantity Variation", digits='unit',
+            help="The difference between "
+            "expected quantity and actual quantity."),
+        'on_change_with_quantity_variation')
+    tolerance_quantity_variation = fields.Function(
+        fields.Float("Tolerance Quantity Variation", digits=(None, 4)),
+        'on_change_with_tolerance_quantity_variation')
+    cost_variation = fields.Function(
+        fields.Numeric("Cost Variation", digits=price_digits),
+        'on_change_with_cost_variation')
+    tolerance_cost_variation = fields.Function(
+        fields.Numeric("Tolerance Cost Variation", digits=(None, 4)),
+        'on_change_with_tolerance_cost_variation')
     moves = fields.One2Many('stock.move', 'origin', 'Moves', readonly=True)
     inventory = fields.Many2One('stock.inventory', 'Inventory', required=True,
         ondelete='CASCADE',
@@ -420,6 +462,57 @@
     def get_unit(self, name):
         return self.product.default_uom
 
+    @fields.depends('quantity', 'expected_quantity', 'unit')
+    def on_change_with_quantity_variation(self, name=None):
+        if self.quantity and self.expected_quantity:
+            diff = self.quantity - self.expected_quantity
+            if self.unit:
+                diff = self.unit.round(diff)
+            return diff
+
+    @fields.depends('expected_quantity', 'inventory',
+        '_parent_inventory.company',
+        methods=['on_change_with_quantity_variation'])
+    def on_change_with_tolerance_quantity_variation(self, name=None):
+        if self.inventory:
+            pool = Pool()
+            Configuration = pool.get('stock.configuration')
+            config = Configuration(1)
+            quantity_tolerance = config.get_multivalue(
+                'inventory_quantity_tolerance',
+                company=self.inventory.company.id)
+            quantity_variation = self.on_change_with_quantity_variation()
+            percentage = None
+            if self.expected_quantity and quantity_variation is not None:
+                percentage = abs(quantity_variation) / self.expected_quantity
+            if quantity_tolerance and percentage is not None:
+                return round(
+                    percentage / quantity_tolerance,
+                    self.__class__.tolerance_quantity_variation.digits[1])
+
+    @fields.depends('product',
+        methods=['on_change_with_quantity_variation'])
+    def on_change_with_cost_variation(self, name=None):
+        quantity_variation = self.on_change_with_quantity_variation()
+        if quantity_variation is not None and self.product:
+            return round_price(
+                self.product.cost_price * Decimal(str(quantity_variation)))
+
+    @fields.depends('expected_quantity', 'inventory',
+        '_parent_inventory.company',
+        methods=['on_change_with_cost_variation'])
+    def on_change_with_tolerance_cost_variation(self, name=None):
+        if self.inventory:
+            pool = Pool()
+            Configuration = pool.get('stock.configuration')
+            config = Configuration(1)
+            cost_thresold = config.get_multivalue(
+                'inventory_cost_thresold',
+                company=self.inventory.company.id)
+            cost_variation = self.on_change_with_cost_variation()
+            if cost_thresold and cost_variation is not None:
+                return round(abs(cost_variation) / cost_thresold, 4)
+
     @property
     def unique_key(self):
         key = []
@@ -497,6 +590,23 @@
                             line=line.rec_name,
                             inventory=line.inventory.rec_name))
 
+    @classmethod
+    def view_attributes(cls):
+        return super().view_attributes() + [
+            ('/tree/field[@name="quantity_variation"]',
+                'visual',
+                If(Eval('tolerance_quantity_variation', 0.0) > 1,
+                    'danger',
+                    If(Eval('tolerance_quantity_variation', 0.0) > .5,
+                        'warning', ''))),
+            ('/tree/field[@name="cost_variation"]',
+                'visual',
+                If(Eval('tolerance_cost_variation', 0.0) > 1,
+                    'danger',
+                    If(Eval('tolerance_cost_variation', 0.0) > .5,
+                        'warning', ''))),
+            ]
+
 
 class Count(Wizard):
     __name__ = 'stock.inventory.count'
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/message.xml
--- a/modules/stock/message.xml Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/message.xml Thu Sep 03 00:02:28 2026 +0200
@@ -83,6 +83,10 @@
         <record model="ir.message" id="msg_inventory_count_create_line">
             <field name="text">No existing line found for "%(search)s".</field>
         </record>
+        <record model="ir.message" id="msg_inventory_over_tolerance">
+            <field name="text">The inventory line "%(lines)s" is outside the 
tolerance range.</field>
+            <field name="text_plural">The inventory lines "%(lines)s" are 
outside the tolerance range.</field>
+        </record>
         <record model="ir.message" id="msg_erase_party_shipment">
             <field name="text">You cannot erase party "%(party)s" while they 
have pending shipments with company "%(company)s".</field>
         </record>
diff -r 020452568c78 -r 06643f7ecb59 
modules/stock/tests/scenario_stock_inventory_tolerance.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/stock/tests/scenario_stock_inventory_tolerance.rst        Thu Sep 
03 00:02:28 2026 +0200
@@ -0,0 +1,130 @@
+==================================
+Stock Inventory Variation Scenario
+==================================
+
+Imports::
+
+    >>> import datetime as dt
+    >>> from decimal import Decimal
+
+    >>> from proteus import Model
+    >>> 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
+
+    >>> today = dt.date.today()
+
+Activate modules::
+
+    >>> config = activate_modules('stock', create_company)
+    >>> Location = Model.get('stock.location')
+    >>> Inventory = Model.get('stock.inventory')
+    >>> ProductUom = Model.get('product.uom')
+    >>> ProductTemplate = Model.get('product.template')
+    >>> StockConfiguration = Model.get('stock.configuration')
+    >>> StockMove = Model.get('stock.move')
+
+Get currency::
+
+    >>> currency = get_currency()
+
+Setup tolerance::
+
+    >>> stock_config = StockConfiguration(1)
+    >>> stock_config.inventory_quantity_tolerance = 0.1
+    >>> stock_config.inventory_cost_thresold = Decimal('200.00')
+    >>> stock_config.save()
+
+Get stock locations::
+
+    >>> supplier_loc, = Location.find([('code', '=', 'SUP')])
+    >>> storage_loc, = Location.find([('code', '=', 'STO')])
+    >>> customer_loc, = Location.find([('code', '=', 'CUS')])
+    >>> lost_found_loc, = Location.find([('type', '=', 'lost_found')])
+
+Create products::
+
+    >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+    >>> template = ProductTemplate()
+    >>> template.name = 'Product'
+    >>> template.default_uom = unit
+    >>> template.type = 'goods'
+    >>> template.list_price = Decimal('300')
+    >>> template.cost_price_method = 'average'
+    >>> product, = template.products
+    >>> product.cost_price = Decimal('80')
+    >>> template.save()
+    >>> product, = template.products
+
+    >>> kg, = ProductUom.find([('name', '=', 'Kilogram')])
+    >>> template2 = ProductTemplate()
+    >>> template2.name = 'Product'
+    >>> template2.default_uom = kg
+    >>> template2.type = 'goods'
+    >>> template2.list_price = Decimal('140')
+    >>> template2.cost_price_method = 'average'
+    >>> product2, = template2.products
+    >>> product2.cost_price = Decimal('60')
+    >>> template2.save()
+    >>> product2, = template2.products
+
+Fill storage::
+
+    >>> incoming_move = StockMove()
+    >>> incoming_move.product = product
+    >>> incoming_move.unit = unit
+    >>> incoming_move.quantity = 1
+    >>> incoming_move.from_location = supplier_loc
+    >>> incoming_move.to_location = storage_loc
+    >>> incoming_move.planned_date = today
+    >>> incoming_move.effective_date = today
+    >>> incoming_move.unit_price = Decimal('100')
+    >>> incoming_move.currency = currency
+    >>> incoming_moves = [incoming_move]
+
+    >>> incoming_move = StockMove()
+    >>> incoming_move.product = product2
+    >>> incoming_move.unit = kg
+    >>> incoming_move.quantity = 2.5
+    >>> incoming_move.from_location = supplier_loc
+    >>> incoming_move.to_location = storage_loc
+    >>> incoming_move.planned_date = today
+    >>> incoming_move.effective_date = today
+    >>> incoming_move.unit_price = Decimal('70')
+    >>> incoming_move.currency = currency
+    >>> incoming_moves.append(incoming_move)
+    >>> StockMove.click(incoming_moves, 'do')
+
+Create an inventory::
+
+    >>> inventory = Inventory()
+    >>> inventory.location = storage_loc
+    >>> inventory.empty_quantity = 'keep'
+    >>> inventory.save()
+    >>> inventory.click('complete_lines')
+    >>> line_by_product = {l.product.id: l for l in inventory.lines}
+
+    >>> line_p1 = line_by_product[product.id]
+    >>> line_p1.expected_quantity
+    1.0
+    >>> line_p1.quantity = 2
+    >>> line_p1.quantity_variation
+    1.0
+    >>> line_p1.tolerance_quantity_variation
+    10.0
+    >>> line_p1.cost_variation
+    Decimal('100.0000')
+
+    >>> line_p2 = line_by_product[product2.id]
+    >>> line_p2.expected_quantity
+    2.5
+    >>> line_p2.quantity = 5
+    >>> line_p2.cost_variation
+    Decimal('175.0000')
+    >>> line_p2.tolerance_cost_variation
+    Decimal('0.8750')
+
+    >>> inventory.click('confirm')
+    Traceback (most recent call last):
+        ...
+    InventoryOverToleranceWarning: ...
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/tryton.cfg
--- a/modules/stock/tryton.cfg  Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/tryton.cfg  Thu Sep 03 00:02:28 2026 +0200
@@ -44,6 +44,7 @@
     configuration.ConfigurationSequence
     configuration.ConfigurationLocation
     configuration.Period
+    configuration.ConfigurationInventory
     location.Location
     location.WarehouseWasteLocation
     location.ProductsByLocationsContext
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/view/configuration_form.xml
--- a/modules/stock/view/configuration_form.xml Fri Sep 04 11:53:57 2026 +0200
+++ b/modules/stock/view/configuration_form.xml Thu Sep 03 00:02:28 2026 +0200
@@ -18,6 +18,16 @@
     <separator id="inventory" colspan="4" string="Inventory"/>
     <label name="inventory_sequence" string="Sequence"/>
     <field name="inventory_sequence"/>
+    <newline/>
+
+    <label name="inventory_quantity_tolerance" string="Quantity Tolerance"/>
+    <group col="-1" id="inventory_quantity_tolerance">
+        <field name="inventory_quantity_tolerance" factor="100" xexpand="0"/>
+        <label name="inventory_quantity_tolerance" string="%" xalign="0.0" 
xexpand="1"/>
+    </group>
+    <label name="inventory_cost_thresold" string="Cost Thresold"/>
+    <field name="inventory_cost_thresold"/>
+
     <separator id="period" colspan="4" string="Period"/>
     <label name="period_creation_interval"/>
     <field name="period_creation_interval"/>
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/view/inventory_line_form.xml
--- a/modules/stock/view/inventory_line_form.xml        Fri Sep 04 11:53:57 
2026 +0200
+++ b/modules/stock/view/inventory_line_form.xml        Thu Sep 03 00:02:28 
2026 +0200
@@ -14,5 +14,10 @@
     <label name="quantity"/>
     <field name="quantity" symbol="unit"/>
 
+    <label name="quantity_variation"/>
+    <field name="quantity_variation" symbol="unit"/>
+    <label name="cost_variation"/>
+    <field name="cost_variation"/>
+
     <field name="moves" colspan="4"/>
 </form>
diff -r 020452568c78 -r 06643f7ecb59 modules/stock/view/inventory_line_list.xml
--- a/modules/stock/view/inventory_line_list.xml        Fri Sep 04 11:53:57 
2026 +0200
+++ b/modules/stock/view/inventory_line_list.xml        Thu Sep 03 00:02:28 
2026 +0200
@@ -6,4 +6,8 @@
     <field name="product" expand="2"/>
     <field name="expected_quantity" symbol="unit"/>
     <field name="quantity" symbol="unit"/>
+    <field name="quantity_variation" symbol="unit" optional="0"/>
+    <field name="tolerance_quantity_variation" tree_invisible="1"/>
+    <field name="cost_variation" optional="1"/>
+    <field name="tolerance_cost_variation" tree_invisible="1"/>
 </tree>

Reply via email to