From b3720e4076425e0878d98ac0cd7a6c7a5587405b Mon Sep 17 00:00:00 2001 From: danal-odoo Date: Thu, 18 Dec 2025 18:14:39 +0530 Subject: [PATCH 1/2] [ADD] last_purchased_products: sort selection by recent customer history Before, the product selection dropdown did not display the time of the previous order and products were not sorted based on how recently they were invoiced for the selected customer. Additionally, the last invoice time and specific quantity calculations were missing from the catalog view. This module enhances the product selection mechanism to assist users in identifying frequently purchased items. Specific improvements include: - Displaying the `last_order` time next to the product name in the dropdown. - Sorting products by `last_invoice_date` specific to the selected customer (partner). - Showing `final_qty` and `last_invoice_time` in the product catalog kanban view. If no customer is selected in the context, the standard selection behavior and sorting apply. Technical Details: - Model `product.product`: Added `last_order` Datetime field, `last_invoice_date` Datetime Field, `last_invoice_time` field. - Model `product.template`: Added `last_order` Datetime field, `last_invoice_date` Datetime Field. - Method `name_search`: Overridden to order products as `last_invoice_date`. - Method `_compute_display_name`: Overridden to show `last_order` as to string time at right side of product name in selection dropdown. - Method `_compute_last_order`: to compute `last_order` of shown products in selection dropdown in context of selected customer(`partner_id`). - Method `_compute_last_invoice_date`: to compute `last_invoice_date` to order recently invoiced first shown products in selection dropdown in context of selected customer(`partner_id`). - Method `compute_agotime`: to compute given Datetime to string format. - View `sale_order_views`: Updated `product_template_id` and catalog button to pass the selected `partner_id` in the context as `customer`. - View `account_move_views`: Updated `product_id` and catalog button to pass the selected `partner_id` in the context as `customer`. - View `purchase_order_views`: Updated `product_id` to pass the selected `partner_id` in the context as `vendor`. - View `product_views`: Updated `product_view_kanban_catalog` to display `final_qty` (`virtual_available` - `qty_available`) next to `On Hand` and `last_invoice_time` after `qty_available` in `product_view_kanban_catalog`. - Template `order_line`: Updated to show `uomDisplayName` next to `price`. --- last_purchased_products/__init__.py | 1 + last_purchased_products/__manifest__.py | 21 ++++ last_purchased_products/models/__init__.py | 4 + last_purchased_products/models/product.py | 119 ++++++++++++++++++ .../models/product_template.py | 104 +++++++++++++++ .../product_catalog/order_line/order_line.xml | 16 +++ .../views/account_move_views.xml | 16 +++ .../views/product_views.xml | 34 +++++ .../views/purchase_order_views.xml | 13 ++ .../views/sale_order_views.xml | 16 +++ 10 files changed, 344 insertions(+) create mode 100644 last_purchased_products/__init__.py create mode 100644 last_purchased_products/__manifest__.py create mode 100644 last_purchased_products/models/__init__.py create mode 100644 last_purchased_products/models/product.py create mode 100644 last_purchased_products/models/product_template.py create mode 100644 last_purchased_products/static/src/product_catalog/order_line/order_line.xml create mode 100644 last_purchased_products/views/account_move_views.xml create mode 100644 last_purchased_products/views/product_views.xml create mode 100644 last_purchased_products/views/purchase_order_views.xml create mode 100644 last_purchased_products/views/sale_order_views.xml diff --git a/last_purchased_products/__init__.py b/last_purchased_products/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/last_purchased_products/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/last_purchased_products/__manifest__.py b/last_purchased_products/__manifest__.py new file mode 100644 index 00000000000..97d299fd6e6 --- /dev/null +++ b/last_purchased_products/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Last Purchased Products", + "version": "1.0", + "depends": ["sale_management", "purchase", "stock"], + "author": "danal", + "category": "Category", + "license": "LGPL-3", + "data": [ + "views/sale_order_views.xml", + "views/account_move_views.xml", + "views/purchase_order_views.xml", + "views/product_views.xml" + ], + "assets": { + 'web.assets_backend': [ + 'last_purchased_products/static/src/product_catalog/**/*.xml', + ] + }, + "installable": True, + "application": False, +} diff --git a/last_purchased_products/models/__init__.py b/last_purchased_products/models/__init__.py new file mode 100644 index 00000000000..28a989df5c8 --- /dev/null +++ b/last_purchased_products/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product_template +from . import product diff --git a/last_purchased_products/models/product.py b/last_purchased_products/models/product.py new file mode 100644 index 00000000000..2a83b60ffbf --- /dev/null +++ b/last_purchased_products/models/product.py @@ -0,0 +1,119 @@ +from dateutil.relativedelta import relativedelta +import datetime + +from odoo import models, api, fields + + +class ProductProduct(models.Model): + _inherit = "product.product" + + last_order = fields.Datetime(compute="_compute_last_order") + last_invoice_date = fields.Datetime(compute="_compute_last_invoice_date") + last_invoice_time = fields.Char(compute="_compute_invoice_time") + + @api.depends_context("customer", "formatted_display_name") + def _compute_display_name(self): + res = super()._compute_display_name() + if not self.env.context.get("customer") or not self.env.context.get( + "formatted_display_name" + ): + return res + compute_agotime_ref = self.compute_agotime + for product in self: + if not product.last_order: + continue + ago = compute_agotime_ref(product.last_order) + current_product_name = product.display_name or "" + if self.env.context.get("formatted_display_name"): + if ago: + time_postfix = f"\t--{ago}--" + else: + time_postfix = "" + product.display_name = f"{current_product_name}{time_postfix}" + else: + product.display_name = f"{current_product_name}" + + @api.depends_context("customer", "vendor") + def _compute_last_invoice_date(self): + customer_id = self.env.context.get("customer") + vendor_id = self.env.context.get("vendor") + domain = [ + ("product_id", "in", self.ids), + ("parent_state", "=", "posted"), + ] + if customer_id: + domain.append(("move_id.move_type", "=", "out_invoice")) + domain.append(("partner_id", "=", customer_id)) + else: + domain.append(("move_id.move_type", "=", "in_invoice")) + domain.append(("partner_id", "=", vendor_id)) + + last_invoice_dates = self.env["account.move.line"].search( + domain, order="create_date desc" + ) + invoice_dates = {} + for invoice in last_invoice_dates: + if invoice.product_id.id not in invoice_dates: + invoice_dates[invoice.product_id.id] = invoice.create_date + for product in self: + product.last_invoice_date = invoice_dates.get(product.id, False) + + @api.depends_context("customer") + def _compute_last_order(self): + last_orders = self.env["sale.order.line"].search( + [ + ("order_id.partner_id", "=", self.env.context.get("customer")), + ("product_id", "in", self.ids), + ("state", "=", "sale"), + ], + order="order_id desc", + ) + order_dates = {} + for order in last_orders: + if order.product_id.id not in order_dates: + order_dates[order.product_id.id] = order.order_id.date_order + for product in self: + product.last_order = order_dates.get(product.id, False) + + @api.depends('last_invoice_date') + def _compute_invoice_time(self): + compute_agotime_ref = self.compute_agotime + for product in self: + if product.last_invoice_date: + ago = compute_agotime_ref(product.last_invoice_date) + if not ago: + ago = "Just Now" + product.last_invoice_time = ago + else: + product.last_invoice_time = False + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + if not self.env.context.get("customer") and not self.env.context.get("vendor"): + return super().name_search(name, args, operator, limit) + res = super().name_search(name, args, operator, limit=100) + ids = [r[0] for r in res] + records = self.browse(ids) + records.mapped("last_invoice_date") + sorted_records = records.sorted( + key=(lambda r: r.last_invoice_date or datetime.datetime.min), reverse=True + ) + return [(r.id, r.display_name) for r in sorted_records][:limit] + + def compute_agotime(self, datetime_field): + now = fields.Datetime.now() + rd = relativedelta(now, datetime_field) + if rd.years: + ago = f"{rd.years}y" + elif rd.months: + ago = f"{rd.months}mo" + elif rd.days: + ago = f"{rd.days}d" + elif rd.hours: + ago = f"{rd.hours}h" + elif rd.minutes: + ago = f"{rd.minutes}m" + else: + ago = "" + + return ago diff --git a/last_purchased_products/models/product_template.py b/last_purchased_products/models/product_template.py new file mode 100644 index 00000000000..ba921b510ae --- /dev/null +++ b/last_purchased_products/models/product_template.py @@ -0,0 +1,104 @@ +from dateutil.relativedelta import relativedelta +import datetime + +from odoo import models, api, fields + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + last_order = fields.Datetime(compute="_compute_last_order") + last_invoice_date = fields.Datetime(compute="_compute_last_invoice_date") + + @api.depends_context("customer", "formatted_display_name") + def _compute_display_name(self): + res = super()._compute_display_name() + if not self.env.context.get("customer") or not self.env.context.get( + "formatted_display_name" + ): + return res + + compute_agotime_ref = self.compute_agotime + for template in self: + if not template.last_order: + continue + ago = compute_agotime_ref(template.last_order) + current_product_template_name = template.display_name or "" + if self.env.context.get("formatted_display_name"): + if ago: + time_postfix = f"\t--{ago}--" + else: + time_postfix = "" + template.display_name = f"{current_product_template_name}{time_postfix}" + else: + template.display_name = f"{current_product_template_name}" + + @api.depends_context("customer") + def _compute_last_order(self): + last_orders = self.env["sale.order.line"].search( + [ + ("order_id.partner_id", "=", self.env.context.get("customer")), + ("product_id.product_tmpl_id", "in", self.ids), + ("state", "=", "sale"), + ], + order="order_id desc", + ) + order_dates = {} + for order in last_orders: + if order.product_id.id not in order_dates: + order_dates[order.product_id.product_tmpl_id.id] = ( + order.order_id.date_order + ) + for template in self: + template.last_order = order_dates.get(template.id, False) + + @api.depends_context("customer") + def _compute_last_invoice_date(self): + last_invoice_dates = self.env["account.move.line"].search( + [ + ("partner_id", "=", self.env.context.get("customer")), + ("product_id.product_tmpl_id", "in", self.ids), + ("parent_state", "=", "posted"), + ], + order="create_date desc", + ) + invoice_dates = {} + for invoice in last_invoice_dates: + if invoice.product_id.id not in invoice_dates: + invoice_dates[invoice.product_id.product_tmpl_id.id] = invoice.create_date + for template in self: + template.last_invoice_date = invoice_dates.get(template.id, False) + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + customer_id = self.env.context.get("customer") + if not customer_id: + return super().name_search(name, args, operator, limit) + res = super().name_search(name, args, operator, limit=100) + ids = [r[0] for r in res] + records = self.browse(ids) + records.mapped("last_invoice_date") + sorted_records = records.sorted( + key=(lambda r: r.last_invoice_date or datetime.datetime.min), reverse=True + ) + return [(r.id, r.display_name) for r in sorted_records][:limit] + + def compute_agotime(self, datetime_field): + now = fields.Datetime.now() + rd = relativedelta(now, datetime_field) + if rd.years: + ago = f"{rd.years}y" + elif rd.months: + ago = f"{rd.months}mo" + elif rd.days: + ago = f"{rd.days}d" + elif rd.hours: + ago = f"{rd.hours}h" + elif rd.minutes: + ago = f"{rd.minutes}m" + elif rd.seconds: + ago = f"{rd.seconds}s" + else: + ago = "" + + return ago diff --git a/last_purchased_products/static/src/product_catalog/order_line/order_line.xml b/last_purchased_products/static/src/product_catalog/order_line/order_line.xml new file mode 100644 index 00000000000..4acb02f648a --- /dev/null +++ b/last_purchased_products/static/src/product_catalog/order_line/order_line.xml @@ -0,0 +1,16 @@ + + + + + o_product_catalog_price fw-bold + + + + / + + + + + + + diff --git a/last_purchased_products/views/account_move_views.xml b/last_purchased_products/views/account_move_views.xml new file mode 100644 index 00000000000..607a77b01e8 --- /dev/null +++ b/last_purchased_products/views/account_move_views.xml @@ -0,0 +1,16 @@ + + + + account.move.form.custom.display + account.move + + + + {'customer': parent.partner_id} + + + {'order_id': parent.id, 'customer': parent.partner_id} + + + + diff --git a/last_purchased_products/views/product_views.xml b/last_purchased_products/views/product_views.xml new file mode 100644 index 00000000000..203d316b46b --- /dev/null +++ b/last_purchased_products/views/product_views.xml @@ -0,0 +1,34 @@ + + + + product.view.kanban.catalog.inherit + product.product + + + + + + + + + ( + + + + + + + + ) + + + + + + + ago + + + + + + diff --git a/last_purchased_products/views/purchase_order_views.xml b/last_purchased_products/views/purchase_order_views.xml new file mode 100644 index 00000000000..ab013b2c323 --- /dev/null +++ b/last_purchased_products/views/purchase_order_views.xml @@ -0,0 +1,13 @@ + + + + purchase.order.form.custom.display + purchase.order + + + + {'vendor': parent.partner_id} + + + + diff --git a/last_purchased_products/views/sale_order_views.xml b/last_purchased_products/views/sale_order_views.xml new file mode 100644 index 00000000000..1c0c2e6cf3e --- /dev/null +++ b/last_purchased_products/views/sale_order_views.xml @@ -0,0 +1,16 @@ + + + + sale.order.form.custom.display + sale.order + + + + {'customer': parent.partner_id} + + + {'order_id': parent.id,'customer': parent.partner_id} + + + + From 6ae505d4dba758fc3e519e7172ff94eb7e452243 Mon Sep 17 00:00:00 2001 From: danal-odoo Date: Mon, 22 Dec 2025 17:40:39 +0530 Subject: [PATCH 2/2] [IMP] last_purchased_products: add test suite for computation and sorting logic This commit introduces the `TestLastOrderProduct` test suite to ensure the stability of product sorting and time computation based on sales and purchase history. Key scenarios covered: Last Order Computation: - Verifies that confirming a Sale Order updates the `last_order` field. - Checks that the 'Ago' time suffix (e.g., '--1h--') is correctly appended to `display_name` when the context is active. Last Invoice Date: - Verifies that posting a Customer Invoice updates `last_invoice_date`. - Ensures valid date comparisons between fields. Vendor Bill Sorting: - Verifies that posting a Vendor Bill updates `last_invoice_date` when in a vendor context. - Ensures products from recent bills appear at the top of search results. Name Search Sorting: - Verifies that recently invoiced products appear at the top of the list in the `name_search` results. - Confirms that sorting falls back to the default (alphabetical) when no customer/vendor context is provided. Time Display Logic: - Validates `last_invoice_time` computation. - Specifically verifies that invoices created less than a minute ago return 'Just Now'. Context Handling: - Ensures logic only triggers when `customer`, `vendor`, or `formatted_display_name` keys are present in the context, preventing unwanted side effects in standard views. --- last_purchased_products/models/product.py | 4 +- last_purchased_products/tests/__init__.py | 1 + .../tests/test_last_order_product.py | 183 ++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 last_purchased_products/tests/__init__.py create mode 100644 last_purchased_products/tests/test_last_order_product.py diff --git a/last_purchased_products/models/product.py b/last_purchased_products/models/product.py index 2a83b60ffbf..fb589a2717e 100644 --- a/last_purchased_products/models/product.py +++ b/last_purchased_products/models/product.py @@ -81,7 +81,7 @@ def _compute_invoice_time(self): for product in self: if product.last_invoice_date: ago = compute_agotime_ref(product.last_invoice_date) - if not ago: + if not ago or ("s" in ago): ago = "Just Now" product.last_invoice_time = ago else: @@ -113,6 +113,8 @@ def compute_agotime(self, datetime_field): ago = f"{rd.hours}h" elif rd.minutes: ago = f"{rd.minutes}m" + elif rd.seconds: + ago = f"{rd.seconds}s" else: ago = "" diff --git a/last_purchased_products/tests/__init__.py b/last_purchased_products/tests/__init__.py new file mode 100644 index 00000000000..738ed1dfce0 --- /dev/null +++ b/last_purchased_products/tests/__init__.py @@ -0,0 +1 @@ +from . import test_last_order_product diff --git a/last_purchased_products/tests/test_last_order_product.py b/last_purchased_products/tests/test_last_order_product.py new file mode 100644 index 00000000000..6fd54e701da --- /dev/null +++ b/last_purchased_products/tests/test_last_order_product.py @@ -0,0 +1,183 @@ +from odoo.tests.common import TransactionCase +from odoo import fields, Command +from datetime import timedelta + + +class TestLastOrderProduct(TransactionCase): + + @classmethod + def setUpClass(self): + super().setUpClass() + + self.partner = self.env["res.users"].create( + { + "name": "Salesman Test User", + "login": "sales_test_user", + "email": "sales_test@example.com", + } + ) + + # Product 1: Will have an Order + self.product = self.env["product.product"].create( + { + "name": "A Ordered Product", + "type": "consu", + "list_price": 100.0, + } + ) + + # Product 2: Will have an Invoice + self.product_second = self.env["product.product"].create( + { + "name": "B Invoiced Product", + "type": "consu", + "list_price": 200.0, + } + ) + + # Create Order for Product 1 + self.order = self.env["sale.order"].create( + { + "partner_id": self.partner.partner_id.id, + "order_line": [ + Command.create( + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 100.0, + } + ) + ], + } + ) + + # Create Invoice for Product 2 + self.invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.partner.partner_id.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + Command.create( + { + "product_id": self.product_second.id, + "quantity": 1, + "price_unit": 200.0, + } + ) + ], + } + ) + + # Create Bill for Product + self.bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.partner.partner_id.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + Command.create( + { + "product_id": self.product.id, + "quantity": 1, + "price_unit": 200.0, + } + ) + ], + } + ) + + self.past_time = fields.Datetime.now() - timedelta(hours=1) + + def test_last_order_computation_name_search(self): + """Test that confirming a sale order updates the last_order and display_name has order time""" + self.order.action_confirm() + self.assertEqual(self.order.state, "sale") + self.order.date_order = self.past_time + ctx = {"customer": self.partner.partner_id.id, "formatted_display_name": True} + product_with_ctx = self.product.with_context(ctx) + product_with_ctx._compute_last_order() + self.assertEqual( + product_with_ctx.last_order, + self.order.date_order, + "Last order date should match the sale order date", + ) + results = ( + self.env["product.product"] + .with_context(ctx) + .name_search(name=self.product.name) + ) + ago = self.product.with_context(ctx).compute_agotime(self.order.date_order) + self.assertEqual(ago, "1h", "Time computed should be 1h") + result_name = results[0][1] + self.assertIn("--1h--", result_name, "Name should contain the time suffix") + + def test_last_invoice_date_computation_name_search(self): + """Test that confirming a create date updates the last_invoice_date and product appear on top""" + self.invoice.action_post() + self.assertEqual(self.invoice.state, "posted") + ctx = {"customer": self.partner.partner_id.id, "formatted_display_name": True} + product_with_ctx = self.product_second.with_context(ctx) + product_with_ctx._compute_last_invoice_date() + self.assertEqual( + product_with_ctx.last_invoice_date, + self.invoice.create_date, + "Last invoice date should match the invoice creation date", + ) + results = self.env["product.product"].with_context(ctx).name_search(name="") + self.assertEqual( + results[0][1], + self.product_second.name, + "Recently invoiced product should be on top.", + ) + + def test_no_customer_name_search(self): + """Test that if no customer is selected then should ive default display_name and sorting""" + self.invoice.action_post() + self.order.action_confirm() + ctx = {"customer": None, "formatted_display_name": True} + results = self.env["product.product"].with_context(ctx).name_search(name="") + result_ids = [r[0] for r in results] + index_product_a = result_ids.index(self.product.id) + index_product_b = result_ids.index(self.product_second.id) + self.assertLess( + index_product_a, + index_product_b, + "Without customer context, sorting should default.", + ) + product_a_result_name = next(r[1] for r in results if r[0] == self.product.id) + self.assertNotIn( + "--", + product_a_result_name, + "Suffix should not be present without customer context", + ) + + def test_last_invoice_time_compute(self): + """Test to compute last_invoice_time which depends on last_invoice_date""" + self.invoice.action_post() + ctx = {"customer": self.partner.partner_id.id, "order_id": self.order.id} + product_with_ctx = self.product_second.with_context(ctx) + product_with_ctx.with_context(ctx)._compute_invoice_time() + self.assertEqual( + product_with_ctx.last_invoice_time, + "Just Now", + "Last invoice time should be 'Just Now' for less then 1 minute older invoices", + ) + + def test_purchase_order_product_sorting(self): + self.bill.action_post() + self.assertEqual(self.bill.state, "posted") + ctx = {"vendor": self.partner.partner_id.id, "formatted_display_name": True} + product_with_ctx = self.product.with_context(ctx) + product_with_ctx._compute_last_invoice_date() + self.assertEqual( + product_with_ctx.last_invoice_date, + self.bill.create_date, + "Last invoice date should match the bill creation date", + ) + results = self.env["product.product"].with_context(ctx).name_search(name="") + self.assertEqual( + results[0][1], + self.product.name, + "Billed product should be at the top for Vendor context", + )