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>