diff --git a/modules/purchase/CHANGELOG b/modules/purchase/CHANGELOG index f255150c71..26ace15629 100644 --- a/modules/purchase/CHANGELOG +++ b/modules/purchase/CHANGELOG @@ -1,3 +1,4 @@ +* Calculate the average lead time for product suppliers Version 8.0.0 - 2026-04-20 -------------------------- diff --git a/modules/purchase/product.py b/modules/purchase/product.py index 34cfe29ca7..da21c2c03e 100644 --- a/modules/purchase/product.py +++ b/modules/purchase/product.py @@ -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): diff --git a/modules/purchase/pyproject.toml b/modules/purchase/pyproject.toml index 44602c110c..378368e0b4 100644 --- a/modules/purchase/pyproject.toml +++ b/modules/purchase/pyproject.toml @@ -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' diff --git a/modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.rst b/modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.rst new file mode 100644 index 0000000000..f2d4ff7808 --- /dev/null +++ b/modules/purchase/tests/scenario_purchase_product_supplier_average_lead_time.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) diff --git a/modules/purchase/view/product_supplier_tree.xml b/modules/purchase/view/product_supplier_tree.xml index 3ccb4295d8..9cf8969713 100644 --- a/modules/purchase/view/product_supplier_tree.xml +++ b/modules/purchase/view/product_supplier_tree.xml @@ -8,4 +8,6 @@ this repository contains the full copyright notices and license terms. --> + + diff --git a/modules/purchase/view/product_supplier_tree_sequence.xml b/modules/purchase/view/product_supplier_tree_sequence.xml index b073b211e9..9d2b71e3b8 100644 --- a/modules/purchase/view/product_supplier_tree_sequence.xml +++ b/modules/purchase/view/product_supplier_tree_sequence.xml @@ -8,4 +8,6 @@ this repository contains the full copyright notices and license terms. --> + +