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} + + + +