details:   https://code.tryton.org/tryton/commit/b0af9504b4a6
branch:    default
user:      Cédric Krier <[email protected]>
date:      Mon Apr 13 13:12:59 2026 +0200
description:
        Calculate average lead time for product suppliers

        Closes #14764
diffstat:

 modules/purchase/CHANGELOG                                                     
 |    1 +
 modules/purchase/product.py                                                    
 |  112 +++++++++-
 modules/purchase/pyproject.toml                                                
 |    5 +-
 
modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.rst 
|   99 ++++++++
 modules/purchase/view/product_supplier_tree.xml                                
 |    2 +
 modules/purchase/view/product_supplier_tree_sequence.xml                       
 |    2 +
 6 files changed, 209 insertions(+), 12 deletions(-)

diffs (304 lines):

diff -r 55a07a868730 -r b0af9504b4a6 modules/purchase/CHANGELOG
--- a/modules/purchase/CHANGELOG        Thu Apr 23 09:18:59 2026 +0200
+++ b/modules/purchase/CHANGELOG        Mon Apr 13 13:12:59 2026 +0200
@@ -1,3 +1,4 @@
+* Calculate the average lead time for product suppliers
 
 Version 8.0.0 - 2026-04-20
 --------------------------
diff -r 55a07a868730 -r b0af9504b4a6 modules/purchase/product.py
--- a/modules/purchase/product.py       Thu Apr 23 09:18:59 2026 +0200
+++ b/modules/purchase/product.py       Mon Apr 13 13:12:59 2026 +0200
@@ -3,8 +3,13 @@
 import datetime
 from collections import defaultdict
 
-from sql.aggregate import Count, Max
+from dateutil.relativedelta import relativedelta
+from sql import Null
+from sql.aggregate import Avg, Count, Max
+from sql.conditionals import Case, Coalesce
+from sql.functions import Age, DateTrunc
 
+from trytond import backend
 from trytond.i18n import gettext
 from trytond.model import (
     Index, MatchMixin, ModelSQL, ModelView, fields, sequence_ordered)
@@ -14,7 +19,7 @@
     price_digits, round_price)
 from trytond.pool import Pool, PoolMeta
 from trytond.pyson import Bool, Eval, If, TimeDelta
-from trytond.tools import is_full_text, lstrip_wildcard
+from trytond.tools import is_full_text, lstrip_wildcard, sqlite_apply_types
 from trytond.transaction import Transaction
 
 from .exceptions import PurchaseUOMWarning
@@ -313,6 +318,12 @@
         help="The time from confirming the purchase order to receiving the "
         "products.\n"
         "If empty the lead time of the supplier is used.")
+    lead_time_used = fields.Function(
+        fields.TimeDelta("Lead Time"),
+        'on_change_with_lead_time_used')
+    average_lead_time = fields.Function(
+        fields.TimeDelta("Average Lead Time"),
+        'get_average_lead_time')
     currency = fields.Many2One('currency.currency', 'Currency', required=True,
         ondelete='RESTRICT')
     unit = fields.Function(
@@ -358,6 +369,78 @@
             if row:
                 self.currency, = row
 
+    @classmethod
+    def get_average_lead_time(cls, product_suppliers, name):
+        return defaultdict(
+            lambda: None, cls._average_lead_time(product_suppliers))
+
+    @classmethod
+    def _average_lead_time(cls, product_suppliers, delta=None, date=None):
+        pool = Pool()
+        Date = pool.get('ir.date')
+        Purchase = pool.get('purchase.purchase')
+        PurchaseLine = pool.get('purchase.line')
+        Product = pool.get('product.product')
+        Move = pool.get('stock.move')
+        table = cls.__table__()
+        cursor = Transaction().connection.cursor()
+
+        purchase = Purchase.__table__()
+        purchase_line = PurchaseLine.__table__()
+        product = Product.__table__()
+        move = Move.__table__()
+
+        if delta is None:
+            delta = relativedelta(years=1)
+        if date is None:
+            date = Date.today()
+        purchase_date = date - delta
+
+        effective_date = Coalesce(
+            move.effective_date,
+            Case((move.planned_date > date, move.planned_date),
+                else_=date))
+
+        query = (purchase
+            .join(purchase_line,
+                condition=((purchase_line.purchase == purchase.id)
+                    & (purchase_line.type == 'line')))
+            .join(product,
+                condition=purchase_line.product == product.id)
+            .join(table,
+                condition=((purchase_line.product_supplier == table.id)
+                   | (
+                        (purchase_line.product_supplier == Null)
+                        & (purchase.party == table.party)
+                        & (
+                            (purchase_line.product == table.product)
+                            | ((product.template == table.template)
+                                & (table.product == Null))))))
+            .join(move,
+                condition=(
+                    move.origin.like('purchase.line,%')
+                    & (Move.origin.sql_id(move.origin, Move)
+                        == purchase_line.id)))
+            .select(
+                table.id.as_('id'),
+                DateTrunc(
+                    'minute',
+                    Avg(Age(effective_date, purchase.purchase_date))
+                    ).as_('lead_time'),
+                where=(
+                    purchase.state.in_(['processing', 'done'])
+                    & (purchase.purchase_date >= purchase_date)
+                    & (purchase.delivery_date == Null)
+                    & (purchase_line.delivery_date_store == Null)
+                    & fields.SQL_OPERATORS['in'](
+                        table.id, [ps.id for ps in product_suppliers])
+                    ),
+                group_by=[table.id]))
+        if backend.name == 'sqlite':
+            sqlite_apply_types(query, [None, 'INTERVAL'])
+        cursor.execute(*query)
+        return dict(cursor)
+
     def get_rec_name(self, name):
         if not self.name and not self.code:
             if self.product:
@@ -403,15 +486,13 @@
         elif self.template:
             return self.template.purchase_uom
 
-    @property
-    def lead_time_used(self):
-        # Use getattr because it can be called with an unsaved instance
-        lead_time = getattr(self, 'lead_time', None)
-        party = getattr(self, 'party', None)
-        if lead_time is None and party:
-            company = getattr(self, 'company', None)
-            lead_time = party.get_multivalue(
-                'supplier_lead_time', company=company.id if company else None)
+    @fields.depends('lead_time', 'party', 'company')
+    def on_change_with_lead_time_used(self, name=None):
+        lead_time = self.lead_time
+        if lead_time is None and self.party:
+            lead_time = self.party.get_multivalue(
+                'supplier_lead_time',
+                company=self.company.id if self.company else None)
         return lead_time
 
     def compute_supply_date(self, date=None):
@@ -446,6 +527,15 @@
             pattern['id'] = context['product_supplier']
         return pattern
 
+    @classmethod
+    def view_attributes(cls):
+        return super().view_attributes() + [
+            ('/tree/field[@name="average_lead_time"]',
+                'visual',
+                If(Eval('average_lead_time', 0) > Eval('lead_time_used', 0),
+                    'danger', '')),
+            ]
+
 
 class ProductSupplierPrice(
         sequence_ordered(), ModelSQL, ModelView, MatchMixin):
diff -r 55a07a868730 -r b0af9504b4a6 modules/purchase/pyproject.toml
--- a/modules/purchase/pyproject.toml   Thu Apr 23 09:18:59 2026 +0200
+++ b/modules/purchase/pyproject.toml   Mon Apr 13 13:12:59 2026 +0200
@@ -51,7 +51,10 @@
 "" = "trytond/modules/purchase"
 
 [tool.hatch.metadata.hooks.tryton]
-dependencies = ['python-sql >= 1.4']
+dependencies = [
+    'python-dateutil',
+    'python-sql >= 1.4',
+    ]
 copyright = 'COPYRIGHT'
 readme = 'README.rst'
 
diff -r 55a07a868730 -r b0af9504b4a6 
modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ 
b/modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.rst
   Mon Apr 13 13:12:59 2026 +0200
@@ -0,0 +1,99 @@
+=======================================================
+Purchase Average Lead Time of Product Supplier Scenario
+=======================================================
+
+Imports::
+
+    >>> import datetime as dt
+    >>> from decimal import Decimal
+
+    >>> from proteus import Model
+    >>> from trytond.modules.account.tests.tools import create_chart, 
get_accounts
+    >>> from trytond.modules.company.tests.tools import create_company
+    >>> from trytond.tests.tools import activate_modules
+
+    >>> today = dt.date.today()
+
+Activate modules::
+
+    >>> config = activate_modules('purchase', create_company, create_chart)
+
+    >>> Party = Model.get('party.party')
+    >>> ProductCategory = Model.get('product.category')
+    >>> ProductSupplier = Model.get('purchase.product_supplier')
+    >>> ProductTemplate = Model.get('product.template')
+    >>> Purchase = Model.get('purchase.purchase')
+    >>> UoM = Model.get('product.uom')
+
+Create suppliers::
+
+    >>> supplier1 = Party(name="Supplier 1")
+    >>> supplier1.save()
+    >>> supplier2 = Party(name="Supplier 2")
+    >>> supplier2.save()
+
+Create account category::
+
+    >>> accounts = get_accounts()
+
+    >>> account_category = ProductCategory(name="Account", accounting=True)
+    >>> account_category.account_expense = accounts['expense']
+    >>> account_category.save()
+
+Create product::
+
+    >>> unit, = UoM.find([('name', '=', "Unit")])
+
+    >>> template = ProductTemplate(name="Product")
+    >>> template.default_uom = unit
+    >>> template.type = 'goods'
+    >>> template.purchasable = True
+    >>> template.account_category = account_category
+    >>> _ = template.product_suppliers.new(party=supplier1)
+    >>> template.save()
+    >>> product, = template.products
+    >>> _ = product.product_suppliers.new(party=supplier2)
+    >>> product.save()
+
+Create purchases::
+
+    >>> purchase = Purchase(party=supplier1)
+    >>> purchase.purchase_date = today - dt.timedelta(days=12)
+    >>> line = purchase.lines.new(product=product)
+    >>> line.quantity = 1
+    >>> line.unit_price = Decimal('42.0000')
+    >>> purchase.click('quote')
+    >>> purchase.click('confirm')
+    >>> purchase.state
+    'processing'
+
+    >>> purchase = Purchase(party=supplier1)
+    >>> purchase.purchase_date = today - dt.timedelta(days=6)
+    >>> line = purchase.lines.new(product=product)
+    >>> line.quantity = 1
+    >>> line.unit_price = Decimal('42.0000')
+    >>> purchase.click('quote')
+    >>> purchase.click('confirm')
+    >>> purchase.state
+    'processing'
+
+    >>> purchase = Purchase(party=supplier2)
+    >>> purchase.purchase_date = today - dt.timedelta(days=5)
+    >>> line = purchase.lines.new(product=product)
+    >>> line.quantity = 1
+    >>> line.unit_price = Decimal('42.0000')
+    >>> line.product_supplier = None
+    >>> purchase.click('quote')
+    >>> purchase.click('confirm')
+    >>> purchase.state
+    'processing'
+
+Check average lead time of product suppliers::
+
+    >>> product_supplier1, = ProductSupplier.find([('party', '=', supplier1)])
+    >>> product_supplier1.average_lead_time
+    datetime.timedelta(days=9)
+
+    >>> product_supplier2, = ProductSupplier.find([('party', '=', supplier2)])
+    >>> product_supplier2.average_lead_time
+    datetime.timedelta(days=5)
diff -r 55a07a868730 -r b0af9504b4a6 
modules/purchase/view/product_supplier_tree.xml
--- a/modules/purchase/view/product_supplier_tree.xml   Thu Apr 23 09:18:59 
2026 +0200
+++ b/modules/purchase/view/product_supplier_tree.xml   Mon Apr 13 13:12:59 
2026 +0200
@@ -8,4 +8,6 @@
     <field name="party" expand="2"/>
     <field name="name" expand="1"/>
     <field name="code" expand="1"/>
+    <field name="lead_time_used" optional="1"/>
+    <field name="average_lead_time" optional="1"/>
 </tree>
diff -r 55a07a868730 -r b0af9504b4a6 
modules/purchase/view/product_supplier_tree_sequence.xml
--- a/modules/purchase/view/product_supplier_tree_sequence.xml  Thu Apr 23 
09:18:59 2026 +0200
+++ b/modules/purchase/view/product_supplier_tree_sequence.xml  Mon Apr 13 
13:12:59 2026 +0200
@@ -8,4 +8,6 @@
     <field name="party" expand="2"/>
     <field name="name" expand="1"/>
     <field name="code" expand="1"/>
+    <field name="lead_time_used" optional="1"/>
+    <field name="average_lead_time" optional="1"/>
 </tree>

Reply via email to