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..fb589a2717e
--- /dev/null
+++ b/last_purchased_products/models/product.py
@@ -0,0 +1,121 @@
+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 or ("s" in 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"
+ elif rd.seconds:
+ ago = f"{rd.seconds}s"
+ 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/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",
+ )
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}
+
+
+
+