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..6a1fe379533
--- /dev/null
+++ b/last_purchased_products/__manifest__.py
@@ -0,0 +1,29 @@
+{
+ "name": "Last Purchased Products",
+ "description": """
+ add last order time next to product display name,
+ order product list as recently to old invoice creation time,
+ add same functionality to customer invoice,
+ add Unit of Measure next to price in catalog kanban view,
+ add recent invoice time after price in catalog kanban view,
+ add customer name next to invoice time.
+ """,
+ "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..34e4cbf10ba
--- /dev/null
+++ b/last_purchased_products/models/product.py
@@ -0,0 +1,150 @@
+import datetime
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+
+
+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")
+ invoice_partner_name = fields.Char(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 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))
+ elif vendor_id:
+ domain.append(("move_id.move_type", "=", "in_invoice"))
+ domain.append(("partner_id", "=", vendor_id))
+ else:
+ domain.append(("move_id.move_type", "=", "out_invoice"))
+
+ last_invoice_dates = self.env["account.move.line"].search(
+ domain, order="create_date desc"
+ )
+ invoice_dates = {}
+ invoice_partners = {}
+ for invoice in last_invoice_dates:
+ if invoice.product_id.id not in invoice_dates:
+ invoice_dates[invoice.product_id.id] = invoice.create_date
+ if invoice.product_id.id not in invoice_partners:
+ invoice_partners[invoice.product_id.id] = invoice.partner_id.name
+ for product in self:
+ product.last_invoice_date = invoice_dates.get(product.id, False)
+ product.invoice_partner_name = invoice_partners.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")),
+ ("state", "=", "sale"),
+ ],
+ order="order_id",
+ )
+ order_dates = {}
+ for order in last_orders:
+ p_id = order.product_id.id
+ if p_id not in order_dates:
+ order_dates[p_id] = order.order_id.date_order
+ for product in self:
+ product.last_order = order_dates.get(product.id, False)
+
+ @api.depends("last_invoice_date")
+ @api.depends_context("isPurchase")
+ def _compute_invoice_time(self):
+ if self.env.context.get("isPurchase"):
+ self.write({"last_invoice_time": False})
+ return
+ 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
+
+ def action_open_last_invoice(self):
+ self.ensure_one()
+ domain = [
+ ("move_id.move_type", "=", "out_invoice"),
+ ("product_id", "=", self.id),
+ ("parent_state", "=", "posted"),
+ ]
+ last_line = self.env["account.move.line"].search(
+ domain, order="create_date desc", limit=1
+ )
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "res_id": last_line.move_id.id,
+ "view_mode": "form",
+ "target": "current",
+ }
+
+ @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..5417d5d2d3c
--- /dev/null
+++ b/last_purchased_products/models/product_template.py
@@ -0,0 +1,27 @@
+from odoo import api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ product_variant_id = fields.Many2one(
+ "product.product", "Product", compute="_compute_product_variant_id", store=True
+ )
+ display_name = fields.Char(related="product_variant_id.display_name")
+
+ @api.depends("product_variant_ids")
+ def _compute_product_variant_id(self):
+ for p in self:
+ p.product_variant_id = p.product_variant_ids[:1].id
+
+ @api.model
+ def name_search(self, name="", args=None, operator="ilike", limit=100):
+ variant_results = self.env["product.product"].name_search(
+ name, args, operator, limit=limit
+ )
+ if not variant_results:
+ return []
+ variant_ids = [res[0] for res in variant_results]
+ variants = self.env["product.product"].browse(variant_ids)
+ templates = variants.mapped("product_tmpl_id")
+ return [(t.id, t.display_name) for t in templates][:limit]
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..a8a745cc3b7
--- /dev/null
+++ b/last_purchased_products/tests/test_last_order_product.py
@@ -0,0 +1,186 @@
+from datetime import timedelta
+
+from odoo import fields, Command
+from odoo.tests.common import TransactionCase, tagged
+
+
+@tagged('post_install', '-at_install')
+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):
+ """Test to confirming recently purchsed product is on top"""
+ 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..f4412f31d93
--- /dev/null
+++ b/last_purchased_products/views/account_move_views.xml
@@ -0,0 +1,23 @@
+
+
+
+ account.move.form.custom.display
+ account.move
+
+
+
+ {
+ 'vendor': parent.partner_id if parent.move_type in ('in_invoice', 'in_refund') else False,
+ 'customer': parent.partner_id if parent.move_type in ('out_invoice','out_refund') else False
+ }
+
+
+
+
+ {'order_id': parent.id, 'isPurchase': parent.move_type in ('in_invoice', 'in_refund')}
+
+
+
+
+
diff --git a/last_purchased_products/views/product_views.xml b/last_purchased_products/views/product_views.xml
new file mode 100644
index 00000000000..94bd30e027a
--- /dev/null
+++ b/last_purchased_products/views/product_views.xml
@@ -0,0 +1,40 @@
+
+
+
+ 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..e42220b13fc
--- /dev/null
+++ b/last_purchased_products/views/purchase_order_views.xml
@@ -0,0 +1,16 @@
+
+
+
+ purchase.order.form.custom.display
+ purchase.order
+
+
+
+ {'vendor': parent.partner_id}
+
+
+ {'order_id': parent.id, 'isPurchase': True}
+
+
+
+
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..17c28fa047b
--- /dev/null
+++ b/last_purchased_products/views/sale_order_views.xml
@@ -0,0 +1,13 @@
+
+
+
+ sale.order.form.custom.display
+ sale.order
+
+
+
+ {'customer': parent.partner_id}
+
+
+
+