Calculate average lead time for product suppliers

Closes #14764
This commit is contained in:
Cédric Krier
2026-04-13 13:12:59 +02:00
parent b83d7055a9
commit ad9f7bf8f2
6 changed files with 209 additions and 12 deletions
+1
View File
@@ -1,3 +1,4 @@
* Calculate the average lead time for product suppliers
Version 8.0.0 - 2026-04-20
--------------------------
+101 -11
View File
@@ -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 @@ from trytond.modules.product import (
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 @@ class ProductSupplier(
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 @@ class ProductSupplier(
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 @@ class ProductSupplier(
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 @@ class ProductSupplier(
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):
+4 -1
View File
@@ -51,7 +51,10 @@ exclude = ['doc']
"" = "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'
@@ -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)
@@ -8,4 +8,6 @@ this repository contains the full copyright notices and license terms. -->
<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>
@@ -8,4 +8,6 @@ this repository contains the full copyright notices and license terms. -->
<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>