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