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>