diff --git a/custom_sale_purchase_display/__init__.py b/custom_sale_purchase_display/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/custom_sale_purchase_display/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/custom_sale_purchase_display/__manifest__.py b/custom_sale_purchase_display/__manifest__.py
new file mode 100644
index 00000000000..670fa6178c4
--- /dev/null
+++ b/custom_sale_purchase_display/__manifest__.py
@@ -0,0 +1,14 @@
+{
+ 'name': 'Sale Order Product History',
+ 'version': '1.0',
+ 'category': 'Sales',
+ 'depends': ['sale_management', 'stock', 'purchase'],
+ 'data': [
+ 'views/purchase_order_form.xml',
+ 'views/sale_order_form_view.xml',
+ 'views/product_template_kanban_catalog.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ 'license': 'LGPL-3'
+}
diff --git a/custom_sale_purchase_display/models/__init__.py b/custom_sale_purchase_display/models/__init__.py
new file mode 100644
index 00000000000..cf9631c7d33
--- /dev/null
+++ b/custom_sale_purchase_display/models/__init__.py
@@ -0,0 +1,3 @@
+from . import sale_order
+from . import product_product
+from . import product_template
diff --git a/custom_sale_purchase_display/models/product_product.py b/custom_sale_purchase_display/models/product_product.py
new file mode 100644
index 00000000000..fbde660dcc3
--- /dev/null
+++ b/custom_sale_purchase_display/models/product_product.py
@@ -0,0 +1,79 @@
+from odoo import models, fields, api
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ last_invoice_date = fields.Date(
+ string="Last Invoice Date",
+ compute="_compute_last_invoice_data",
+ store=False
+ )
+ last_invoice_time_diff = fields.Char(
+ string="Last Invoice Time Diff",
+ compute="_compute_last_invoice_data",
+ store=False
+ )
+
+ def _compute_last_invoice_data(self):
+ for product in self:
+ partner_id = product.env.context.get('sale_order_partner_id') \
+ or product.env.context.get('purchase_order_partner_id')
+
+ is_sale = bool(product.env.context.get('sale_order_partner_id'))
+ move_type = 'out_invoice' if is_sale else 'in_invoice'
+
+ domain = [
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False),
+ ('line_ids.product_id', '=', product.id),
+ ('move_type', '=', move_type),
+ ]
+ if partner_id:
+ domain.append(('partner_id', '=', partner_id))
+
+ move = product.env['account.move'].search(
+ domain, order='invoice_date desc', limit=1
+ )
+
+ product.last_invoice_date = move.invoice_date if move else False
+ product.last_invoice_time_diff = (
+ self._format_time_diff(move.invoice_date) if move else False
+ )
+
+ @api.model
+ def _get_recent_invoices(self, partner_id, is_sale=True):
+ if not partner_id:
+ return []
+
+ move_type = 'out_invoice' if is_sale else 'in_invoice'
+ moves = self.env['account.move'].search([
+ ('partner_id', '=', partner_id),
+ ('move_type', '=', move_type),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False)
+ ], order='invoice_date desc')
+
+ recent, seen = [], set()
+ for mv in moves:
+ for line in mv.line_ids.filtered('product_id'):
+ pid = line.product_id.id
+ if pid not in seen:
+ recent.append({'pid': pid, 'date': mv.invoice_date})
+ seen.add(pid)
+ return recent
+
+ @api.model
+ def _format_time_diff(self, invoice_date):
+ if not invoice_date:
+ return ""
+ days = (fields.Date.today() - invoice_date).days
+ if days > 365:
+ return f"{days // 365}y ago"
+ if days > 30:
+ return f"{days // 30}mo ago"
+ if days > 7:
+ return f"{days // 7}w ago"
+ if days > 0:
+ return f"{days}d ago"
+ return "today"
diff --git a/custom_sale_purchase_display/models/product_template.py b/custom_sale_purchase_display/models/product_template.py
new file mode 100644
index 00000000000..1e76394b4a1
--- /dev/null
+++ b/custom_sale_purchase_display/models/product_template.py
@@ -0,0 +1,68 @@
+from odoo import models, fields, api
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ last_invoice_date = fields.Date(
+ string="Last Invoice Date",
+ related='product_variant_id.last_invoice_date',
+ store=False
+ )
+ last_invoice_time_diff = fields.Char(
+ string="Last Invoice Time Diff",
+ related='product_variant_id.last_invoice_time_diff',
+ store=False
+ )
+
+ product_variant_id = fields.Many2one(
+ 'product.product',
+ compute='_compute_product_variant_id',
+ store=True,
+ index=True
+ )
+
+ @api.model
+ def name_search(self, name="", args=None, operator="ilike", limit=100):
+ args = args or []
+
+ partner_id = self.env.context.get('sale_order_partner_id') \
+ or self.env.context.get('purchase_order_partner_id')
+ if not partner_id:
+ return super().name_search(name, args, operator, limit)
+
+ is_sale = bool(self.env.context.get('sale_order_partner_id'))
+ recent_lines = self.env['product.product']._get_recent_invoices(
+ partner_id=partner_id,
+ is_sale=is_sale
+ )
+
+ if not recent_lines:
+ return super().name_search(name, args, operator, limit)
+
+ recent_template_ids = list(dict.fromkeys(
+ self.env['product.product'].browse(rl['pid']).product_tmpl_id.id
+ for rl in recent_lines
+ ))
+
+ base_domain = [('name', operator, name)] + args
+
+ recent_templates = self.search(
+ [('id', 'in', recent_template_ids)] + base_domain,
+ limit=limit
+ )
+ other_templates = self.search(
+ [('id', 'not in', recent_template_ids)] + base_domain,
+ limit=max(0, limit - len(recent_templates))
+ )
+
+ results = []
+ for tmpl_id in recent_template_ids:
+ tmpl = recent_templates.filtered(lambda t: t.id == tmpl_id)
+ if tmpl:
+ td = tmpl.last_invoice_time_diff
+ label = f"{tmpl.display_name} ⏱ {td}" if td else tmpl.display_name
+ results.append((tmpl.id, label))
+
+ results.extend((t.id, t.display_name) for t in other_templates)
+ return results
diff --git a/custom_sale_purchase_display/models/sale_order.py b/custom_sale_purchase_display/models/sale_order.py
new file mode 100644
index 00000000000..7454b8bfd08
--- /dev/null
+++ b/custom_sale_purchase_display/models/sale_order.py
@@ -0,0 +1,173 @@
+from odoo import models, fields, api
+from datetime import datetime
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ # Computed stock-related fields to be shown per product line
+ forecasted_qty = fields.Float(string="Forecasted Qty", compute='_compute_forecasted_qty', store=False)
+ qty_on_hand = fields.Float(string="On Hand Qty", compute='_compute_qty_on_hand', store=False)
+ qty_difference = fields.Float(string="Qty Difference", compute='_compute_qty_difference', store=False)
+
+ @api.depends('product_id')
+ def _compute_forecasted_qty(self):
+ """Fetches forecasted quantity (virtual stock) for selected product."""
+ for line in self:
+ if line.product_id:
+ line.forecasted_qty = line.product_id.virtual_available
+ else:
+ line.forecasted_qty = 0.0
+
+ @api.depends('product_id')
+ def _compute_qty_on_hand(self):
+ """Fetches current on-hand quantity for selected product."""
+ for line in self:
+ if line.product_id:
+ line.qty_on_hand = line.product_id.qty_available
+ else:
+ line.qty_on_hand = 0.0
+
+ @api.depends('forecasted_qty', 'qty_on_hand')
+ def _compute_qty_difference(self):
+ """Computes difference between forecasted and on-hand quantity."""
+ for line in self:
+ line.qty_difference = line.forecasted_qty - line.qty_on_hand
+
+
+# Inherit SaleOrder to display last sold products for a customer
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ # Computed list of recent products sold to the selected customer
+ last_sold_products = fields.Many2many(
+ 'product.product',
+ compute='_compute_last_sold_products',
+ string="Last 5 Sold Products"
+ )
+
+ # Last invoice date for the selected customer
+ last_invoice_date = fields.Date(
+ compute='_compute_last_invoice_date',
+ string="Last Invoice Date"
+ )
+
+ # Textual info summary of last sold products and invoice timestamps
+ last_sold_products_info = fields.Text(
+ compute='_compute_last_sold_products_info',
+ string="Last Sold Products Info"
+ )
+
+ # Used to conditionally hide info block if there's no history
+ invisible_last_sold_info = fields.Boolean(
+ compute='_compute_invisible_last_sold_info',
+ string="Hide Last Sold Info"
+ )
+
+ @api.depends('partner_id')
+ def _compute_last_sold_products(self):
+ """
+ Retrieves unique products from posted customer invoices,
+ ordered by invoice date for selected partner.
+ """
+ for order in self:
+ if not order.partner_id:
+ order.last_sold_products = [(5, 0, 0)]
+ continue
+
+ # Search for invoice lines with products for the customer
+ lines = self.env['account.move.line'].search([
+ ('move_id.partner_id', '=', order.partner_id.id),
+ ('move_id.move_type', '=', 'out_invoice'),
+ ('move_id.state', '=', 'posted'),
+ ('display_type', '=', 'product'),
+ ('product_id', '!=', False)
+ ], order='date desc, id desc')
+
+ # Deduplicate products to keep the latest 5 sold ones
+ product_ids_ordered = []
+ seen_products = set()
+ for line in lines:
+ if line.product_id.id not in seen_products:
+ product_ids_ordered.append(line.product_id.id)
+ seen_products.add(line.product_id.id)
+
+ order.last_sold_products = [(6, 0, product_ids_ordered)]
+
+ @api.depends('partner_id')
+ def _compute_last_invoice_date(self):
+ """
+ Finds the most recent invoice date for this customer.
+ """
+ for order in self:
+ if not order.partner_id:
+ order.last_invoice_date = False
+ continue
+
+ last_invoice = self.env['account.move'].search([
+ ('partner_id', '=', order.partner_id.id),
+ ('move_type', '=', 'out_invoice'),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False)
+ ], order='invoice_date desc', limit=1)
+
+ order.last_invoice_date = last_invoice.invoice_date if last_invoice else False
+
+ @api.depends('last_sold_products', 'partner_id')
+ def _compute_last_sold_products_info(self):
+ """
+ Generates readable lines like:
+ • Product A (Invoiced 3 days ago on 2024-06-15)
+ """
+ for order in self:
+ if not order.last_sold_products:
+ order.last_sold_products_info = "No recent products found for this customer."
+ continue
+
+ product_ids = order.last_sold_products.ids
+ last_invoice_dates = {}
+
+ # Find invoice dates per product
+ all_lines = self.env['account.move.line'].search([
+ ('move_id.partner_id', '=', order.partner_id.id),
+ ('product_id', 'in', product_ids),
+ ('move_id.move_type', '=', 'out_invoice'),
+ ('move_id.state', '=', 'posted'),
+ ('move_id.invoice_date', '!=', False)
+ ])
+
+ for line in all_lines:
+ product_id = line.product_id.id
+ invoice_date = line.move_id.invoice_date
+ if product_id not in last_invoice_dates or invoice_date > last_invoice_dates.get(product_id):
+ last_invoice_dates[product_id] = invoice_date
+
+ info_lines = []
+ current_dt = fields.Datetime.now()
+
+ for product in order.last_sold_products:
+ last_date = last_invoice_dates.get(product.id)
+ if last_date:
+ # Time difference from now to last invoice date
+ invoice_dt = datetime.combine(last_date, datetime.min.time())
+ time_diff = current_dt - invoice_dt
+ days = time_diff.days
+
+ if days > 1:
+ time_str = f"{days} days ago"
+ elif days == 1:
+ time_str = "1 day ago"
+ else:
+ time_str = f"{time_diff.seconds // 3600} hours ago"
+
+ info_lines.append(f"• {product.display_name} (Invoiced {time_str} on {last_date.strftime('%Y-%m-%d')})")
+ else:
+ info_lines.append(f"• {product.display_name} (No recent invoice found)")
+
+ order.last_sold_products_info = "\n".join(info_lines)
+
+ @api.depends('last_sold_products')
+ def _compute_invisible_last_sold_info(self):
+ """Boolean toggle: hide info block if no products available."""
+ for order in self:
+ order.invisible_last_sold_info = not bool(order.last_sold_products)
diff --git a/custom_sale_purchase_display/views/product_template_kanban_catalog.xml b/custom_sale_purchase_display/views/product_template_kanban_catalog.xml
new file mode 100644
index 00000000000..76e4fc7f9fa
--- /dev/null
+++ b/custom_sale_purchase_display/views/product_template_kanban_catalog.xml
@@ -0,0 +1,33 @@
+
+
+
+ product.product.kanban.inherit.catalog
+ product.product
+
+
+
+
+
+
+
+
+
+ (+ )
+
+
+ ()
+
+
+ (0)
+
+
+
+ ⏱
+
+
+ 📅
+
+
+
+
+
diff --git a/custom_sale_purchase_display/views/purchase_order_form.xml b/custom_sale_purchase_display/views/purchase_order_form.xml
new file mode 100644
index 00000000000..0e15193cc31
--- /dev/null
+++ b/custom_sale_purchase_display/views/purchase_order_form.xml
@@ -0,0 +1,16 @@
+
+
+
+ purchase.order.form.inherit.custom
+ purchase.order
+
+
+
+ {'purchase_order_partner_id': parent.partner_id}
+
+
+
+
+
diff --git a/custom_sale_purchase_display/views/sale_order_form_view.xml b/custom_sale_purchase_display/views/sale_order_form_view.xml
new file mode 100644
index 00000000000..8fdbfb036ea
--- /dev/null
+++ b/custom_sale_purchase_display/views/sale_order_form_view.xml
@@ -0,0 +1,40 @@
+
+
+
+ sale.order.form.inherit.custom
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {'sale_order_partner_id': parent.partner_id}
+
+
+ {'sale_order_partner_id': parent.partner_id}
+
+
+
+