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..feb3c5eeea7
--- /dev/null
+++ b/custom_sale_purchase_display/models/product_product.py
@@ -0,0 +1,170 @@
+from odoo import models, fields, api
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ # Computed field showing the latest invoice date for this product (context-sensitive)
+ last_invoice_date = fields.Date(
+ string="Last Invoice Date",
+ compute="_compute_last_invoice_date",
+ store=False
+ )
+
+ # Computed field showing time difference from today to last invoice date
+ last_invoice_time_diff = fields.Char(
+ string="Last Invoice Time Diff",
+ compute="_compute_last_invoice_time_diff",
+ store=False
+ )
+
+ def _compute_last_invoice_date(self):
+ for product in self:
+ partner_id = self.env.context.get('sale_order_partner_id') or self.env.context.get('purchase_order_partner_id')
+ if partner_id:
+ domain = [
+ ('partner_id', '=', partner_id),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False),
+ ('line_ids.product_id', '=', product.id)
+ ]
+ if self.env.context.get('sale_order_partner_id'):
+ domain += [('move_type', '=', 'out_invoice')]
+ else:
+ domain += [('move_type', '=', 'in_invoice')]
+ move = self.env['account.move'].search(domain, order='invoice_date desc', limit=1)
+ product.last_invoice_date = move.invoice_date if move else False
+ else:
+ # Fallback: search globally for latest sales invoice
+ domain = [
+ ('move_type', '=', 'out_invoice'),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False),
+ ('line_ids.product_id', '=', product.id)
+ ]
+ move = self.env['account.move'].search(domain, order='invoice_date desc', limit=1)
+ product.last_invoice_date = move.invoice_date if move else False
+
+ def _compute_last_invoice_time_diff(self):
+ for product in self:
+ if product.last_invoice_date:
+ diff = fields.Date.today() - product.last_invoice_date
+ days = diff.days
+ if days > 365:
+ product.last_invoice_time_diff = f"{days // 365}y ago"
+ elif days > 30:
+ product.last_invoice_time_diff = f"{days // 30}mo ago"
+ elif days > 7:
+ product.last_invoice_time_diff = f"{days // 7}w ago"
+ elif days > 0:
+ product.last_invoice_time_diff = f"{days}d ago"
+ else:
+ product.last_invoice_time_diff = "today"
+ else:
+ product.last_invoice_time_diff = False
+
+ @api.model
+ def _get_recent_sales(self, partner_id):
+ if not partner_id:
+ return []
+ domain = [
+ ('partner_id', '=', partner_id),
+ ('move_type', '=', 'out_invoice'),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False)
+ ]
+ moves = self.env['account.move'].search(domain)
+ recent_sales = []
+ seen_products = set()
+ for move in moves.sorted(key=lambda m: m.invoice_date, reverse=True):
+ for line in move.line_ids:
+ pid = line.product_id.id
+ if pid and pid not in seen_products:
+ recent_sales.append({'pid': pid, 'date': move.invoice_date})
+ seen_products.add(pid)
+ return recent_sales
+
+ @api.model
+ def _get_recent_purchases(self, partner_id):
+ if not partner_id:
+ return []
+ domain = [
+ ('partner_id', '=', partner_id),
+ ('move_type', '=', 'in_invoice'),
+ ('state', '=', 'posted'),
+ ('invoice_date', '!=', False)
+ ]
+ moves = self.env['account.move'].search(domain)
+ recent_purchases = []
+ seen_products = set()
+ for move in moves.sorted(key=lambda m: m.invoice_date, reverse=True):
+ for line in move.line_ids:
+ pid = line.product_id.id
+ if pid and pid not in seen_products:
+ recent_purchases.append({'pid': pid, 'date': move.invoice_date})
+ seen_products.add(pid)
+ return recent_purchases
+
+ @api.model
+ def _format_time_diff(self, date_input):
+ date = date_input.get('date') if isinstance(date_input, dict) else date_input
+ if not date:
+ return ""
+ diff = fields.Date.today() - date
+ days = diff.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"
+
+ @api.model
+ def name_search(self, name="", args=None, operator="ilike", limit=100):
+ args = args or []
+ sale_partner_id = self.env.context.get('sale_order_partner_id')
+ purchase_partner_id = self.env.context.get('purchase_order_partner_id')
+ partner_id = sale_partner_id or purchase_partner_id
+ if not partner_id:
+ return super().name_search(name, args, operator, limit)
+
+ # Get product history data
+ if sale_partner_id:
+ history_data = self._get_recent_sales(partner_id)
+ else:
+ history_data = self._get_recent_purchases(partner_id)
+
+ if not history_data:
+ return super().name_search(name, args, operator, limit)
+
+ # Sort product IDs based on most recent usage
+ sorted_history = sorted(history_data, key=lambda x: x['date'], reverse=True)
+ product_ids_ordered = [item['pid'] for item in sorted_history]
+ history_map = {item['pid']: item for item in sorted_history}
+
+ # Prioritize search based on historical relevance
+ domain = [('product_variant_ids.id', 'in', product_ids_ordered), ('name', operator, name)] + args
+ prioritized_products = self.search(domain).sorted(
+ key=lambda p: product_ids_ordered.index(p.product_variant_id.id if p.product_variant_id else p.id)
+ )
+
+ results = []
+ prioritized_ids = []
+ for product in prioritized_products:
+ item = history_map.get(product.id)
+ time_str = self._format_time_diff(item) if item else ""
+ display_name = f"{product.display_name} ⏱ {time_str}" if time_str else product.display_name
+ results.append((product.id, display_name))
+ prioritized_ids.append(product.id)
+
+ # Include additional matching products if under limit
+ other_limit = limit - len(results)
+ if other_limit > 0:
+ domain = [('id', 'not in', prioritized_ids), ('name', operator, name)] + args
+ other_products = self.search(domain, limit=other_limit)
+ results.extend([(p.id, p.display_name) for p in other_products])
+
+ return results
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..4164e95d0c0
--- /dev/null
+++ b/custom_sale_purchase_display/models/product_template.py
@@ -0,0 +1,76 @@
+from odoo import models, fields, api
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ # Related fields to show recent invoice data for a product variant (read-only)
+ 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
+ )
+
+ # Helper field to get the main variant of the template (used in searching/mapping)
+ 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 []
+ # Get customer/vendor ID from context
+ 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)
+
+ # Fetch recent product interaction history based on context
+ if self.env.context.get('sale_order_partner_id'):
+ history_data = self.env['product.product']._get_recent_sales(partner_id)
+ else:
+ history_data = self.env['product.product']._get_recent_purchases(partner_id)
+
+ if not history_data:
+ return super().name_search(name, args, operator, limit)
+
+ # Build a lookup of product ID to history entry
+ history_map = {item['pid']: item for item in history_data}
+ variant_ids = list(history_map.keys())
+ # Find related product templates from those variants
+ templates = self.env['product.product'].browse(variant_ids).mapped('product_tmpl_id')
+
+ # Prioritize templates based on history order
+ domain = [('id', 'in', templates.ids), ('name', operator, name)] + args
+ prioritized_templates = templates.search(domain).sorted(
+ key=lambda t: min(
+ [history_data.index(d) for d in history_data if d['pid'] in t.product_variant_ids.ids]
+ or [len(history_data)]
+ )
+ )
+
+ results = []
+ for tmpl in prioritized_templates:
+ variant_id = tmpl.product_variant_id.id if tmpl.product_variant_ids else False
+ item = history_map.get(variant_id)
+ # Add time difference if available to display name
+ time_str = self.env['product.product']._format_time_diff(item) if item else ""
+ display_name = f"{tmpl.display_name} ⏱ {time_str}" if time_str else tmpl.display_name
+ results.append((tmpl.id, display_name))
+
+ # If results are fewer than limit, append others that matched but not in history
+ other_limit = limit - len(results)
+ if other_limit > 0:
+ domain = [('id', 'not in', prioritized_templates.ids), ('name', operator, name)] + args
+ other_templates = self.search(domain, limit=other_limit)
+ 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..03255410e82
--- /dev/null
+++ b/custom_sale_purchase_display/models/sale_order.py
@@ -0,0 +1,192 @@
+from odoo import models, fields, api
+from datetime import datetime
+
+
+# Inherit SaleOrderLine to show live stock info per line
+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)
+
+ def action_open_catalog(self):
+ """
+ Opens product catalog modal in Kanban view, filtered by selected partner.
+ Useful to re-sell previously bought items.
+ """
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Product Catalog',
+ 'res_model': 'product.product',
+ 'view_mode': 'kanban',
+ 'view_id': self.env.ref('product.product_view_kanban_catalog').id,
+ 'target': 'new',
+ 'context': {
+ 'sale_order_partner_id': self.partner_id.id,
+ 'default_partner_id': self.partner_id.id,
+ }
+ }
diff --git a/custom_sale_purchase_display/models/sale_order_line.py b/custom_sale_purchase_display/models/sale_order_line.py
new file mode 100644
index 00000000000..9179263d940
--- /dev/null
+++ b/custom_sale_purchase_display/models/sale_order_line.py
@@ -0,0 +1,51 @@
+from odoo import models, api
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ @api.model
+ def name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
+ """ Prioritize previously sold products in product_id dropdown """
+ args = args or []
+
+ # Get partner_id from context (typically set via Sale Order form view)
+ partner_id = self.env.context.get('partner_id')
+ if partner_id:
+ # Get invoice lines for this partner that include a product
+ invoice_lines = self.env['account.move.line'].search([
+ ('move_id.partner_id', '=', partner_id),
+ ('move_id.move_type', '=', 'out_invoice'),
+ ('product_id', '!=', False)
+ ])
+
+ # Sort by latest invoice date or fallback to other dates
+ sorted_lines = sorted(
+ invoice_lines,
+ key=lambda l: l.move_id.invoice_date or l.move_id.date or l.create_date,
+ reverse=True
+ )
+
+ # Build a list of unique product IDs ordered by recency
+ seen = set()
+ sorted_product_ids = []
+ for line in sorted_lines:
+ pid = line.product_id.id
+ if pid not in seen:
+ sorted_product_ids.append(pid)
+ seen.add(pid)
+
+ # First show previously purchased products
+ preferred_products = self.env['product.product'].search(
+ [('id', 'in', sorted_product_ids)] + args, limit=limit
+ )
+
+ # Then fallback to other matches if limit allows
+ other_products = self.env['product.product'].search(
+ [('id', 'not in', sorted_product_ids)] + args, limit=limit
+ )
+
+ return preferred_products.name_get() + other_products.name_get()
+
+ # Fallback to default behavior
+ return super().name_search(name=name, args=args, operator=operator, limit=limit)
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..cf1958f16d7
--- /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}
+
+
+
+