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