Skip to content

Commit 39202ae

Browse files
committed
[IMP] sale: enhance product listing and display on sales/purchase forms
This commit improves the user experience when selecting products on Sale Orders and Purchase Orders: - In Sale Orders, prioritize displaying products already sold to the selected customer, sorted from most recent to oldest invoice. Other products are shown afterward in standard order. - Display the last invoice date on the right side of the product card. - Include forecasted quantity and on-hand quantity directly on the product card. If there's a difference, show it in red (-) or green (+), without using brackets. - Apply the same sorting logic to Customer Invoices for consistency. - Adjust the Kanban catalog layout to remove color tags and sort products by last bill in Purchase Orders.
1 parent fbf9ee9 commit 39202ae

File tree

10 files changed

+597
-0
lines changed

10 files changed

+597
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
'name': 'Sale Order Product History',
3+
'version': '1.0',
4+
'category': 'Sales',
5+
'depends': ['sale_management', 'stock', 'purchase'],
6+
'data': [
7+
'views/purchase_order_form.xml',
8+
'views/sale_order_form_view.xml',
9+
'views/product_template_kanban_catalog.xml',
10+
],
11+
'installable': True,
12+
'application': False,
13+
'license': 'LGPL-3'
14+
}
15+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import sale_order
2+
from . import product_product
3+
from . import product_template
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from odoo import models, fields, api
2+
3+
4+
class ProductProduct(models.Model):
5+
_inherit = 'product.product'
6+
7+
# Computed field showing the latest invoice date for this product (context-sensitive)
8+
last_invoice_date = fields.Date(
9+
string="Last Invoice Date",
10+
compute="_compute_last_invoice_date",
11+
store=False
12+
)
13+
14+
# Computed field showing time difference from today to last invoice date
15+
last_invoice_time_diff = fields.Char(
16+
string="Last Invoice Time Diff",
17+
compute="_compute_last_invoice_time_diff",
18+
store=False
19+
)
20+
21+
def _compute_last_invoice_date(self):
22+
for product in self:
23+
partner_id = self.env.context.get('sale_order_partner_id') or self.env.context.get('purchase_order_partner_id')
24+
if partner_id:
25+
domain = [
26+
('partner_id', '=', partner_id),
27+
('state', '=', 'posted'),
28+
('invoice_date', '!=', False),
29+
('line_ids.product_id', '=', product.id)
30+
]
31+
if self.env.context.get('sale_order_partner_id'):
32+
domain += [('move_type', '=', 'out_invoice')]
33+
else:
34+
domain += [('move_type', '=', 'in_invoice')]
35+
move = self.env['account.move'].search(domain, order='invoice_date desc', limit=1)
36+
product.last_invoice_date = move.invoice_date if move else False
37+
else:
38+
# Fallback: search globally for latest sales invoice
39+
domain = [
40+
('move_type', '=', 'out_invoice'),
41+
('state', '=', 'posted'),
42+
('invoice_date', '!=', False),
43+
('line_ids.product_id', '=', product.id)
44+
]
45+
move = self.env['account.move'].search(domain, order='invoice_date desc', limit=1)
46+
product.last_invoice_date = move.invoice_date if move else False
47+
48+
def _compute_last_invoice_time_diff(self):
49+
for product in self:
50+
if product.last_invoice_date:
51+
diff = fields.Date.today() - product.last_invoice_date
52+
days = diff.days
53+
if days > 365:
54+
product.last_invoice_time_diff = f"{days // 365}y ago"
55+
elif days > 30:
56+
product.last_invoice_time_diff = f"{days // 30}mo ago"
57+
elif days > 7:
58+
product.last_invoice_time_diff = f"{days // 7}w ago"
59+
elif days > 0:
60+
product.last_invoice_time_diff = f"{days}d ago"
61+
else:
62+
product.last_invoice_time_diff = "today"
63+
else:
64+
product.last_invoice_time_diff = False
65+
66+
@api.model
67+
def _get_recent_sales(self, partner_id):
68+
if not partner_id:
69+
return []
70+
domain = [
71+
('partner_id', '=', partner_id),
72+
('move_type', '=', 'out_invoice'),
73+
('state', '=', 'posted'),
74+
('invoice_date', '!=', False)
75+
]
76+
moves = self.env['account.move'].search(domain)
77+
recent_sales = []
78+
seen_products = set()
79+
for move in moves.sorted(key=lambda m: m.invoice_date, reverse=True):
80+
for line in move.line_ids:
81+
pid = line.product_id.id
82+
if pid and pid not in seen_products:
83+
recent_sales.append({'pid': pid, 'date': move.invoice_date})
84+
seen_products.add(pid)
85+
return recent_sales
86+
87+
@api.model
88+
def _get_recent_purchases(self, partner_id):
89+
if not partner_id:
90+
return []
91+
domain = [
92+
('partner_id', '=', partner_id),
93+
('move_type', '=', 'in_invoice'),
94+
('state', '=', 'posted'),
95+
('invoice_date', '!=', False)
96+
]
97+
moves = self.env['account.move'].search(domain)
98+
recent_purchases = []
99+
seen_products = set()
100+
for move in moves.sorted(key=lambda m: m.invoice_date, reverse=True):
101+
for line in move.line_ids:
102+
pid = line.product_id.id
103+
if pid and pid not in seen_products:
104+
recent_purchases.append({'pid': pid, 'date': move.invoice_date})
105+
seen_products.add(pid)
106+
return recent_purchases
107+
108+
@api.model
109+
def _format_time_diff(self, date_input):
110+
date = date_input.get('date') if isinstance(date_input, dict) else date_input
111+
if not date:
112+
return ""
113+
diff = fields.Date.today() - date
114+
days = diff.days
115+
if days > 365:
116+
return f"{days // 365}y ago"
117+
if days > 30:
118+
return f"{days // 30}mo ago"
119+
if days > 7:
120+
return f"{days // 7}w ago"
121+
if days > 0:
122+
return f"{days}d ago"
123+
return "today"
124+
125+
@api.model
126+
def name_search(self, name="", args=None, operator="ilike", limit=100):
127+
args = args or []
128+
sale_partner_id = self.env.context.get('sale_order_partner_id')
129+
purchase_partner_id = self.env.context.get('purchase_order_partner_id')
130+
partner_id = sale_partner_id or purchase_partner_id
131+
if not partner_id:
132+
return super().name_search(name, args, operator, limit)
133+
134+
# Get product history data
135+
if sale_partner_id:
136+
history_data = self._get_recent_sales(partner_id)
137+
else:
138+
history_data = self._get_recent_purchases(partner_id)
139+
140+
if not history_data:
141+
return super().name_search(name, args, operator, limit)
142+
143+
# Sort product IDs based on most recent usage
144+
sorted_history = sorted(history_data, key=lambda x: x['date'], reverse=True)
145+
product_ids_ordered = [item['pid'] for item in sorted_history]
146+
history_map = {item['pid']: item for item in sorted_history}
147+
148+
# Prioritize search based on historical relevance
149+
domain = [('product_variant_ids.id', 'in', product_ids_ordered), ('name', operator, name)] + args
150+
prioritized_products = self.search(domain).sorted(
151+
key=lambda p: product_ids_ordered.index(p.product_variant_id.id if p.product_variant_id else p.id)
152+
)
153+
154+
results = []
155+
prioritized_ids = []
156+
for product in prioritized_products:
157+
item = history_map.get(product.id)
158+
time_str = self._format_time_diff(item) if item else ""
159+
display_name = f"{product.display_name}{time_str}" if time_str else product.display_name
160+
results.append((product.id, display_name))
161+
prioritized_ids.append(product.id)
162+
163+
# Include additional matching products if under limit
164+
other_limit = limit - len(results)
165+
if other_limit > 0:
166+
domain = [('id', 'not in', prioritized_ids), ('name', operator, name)] + args
167+
other_products = self.search(domain, limit=other_limit)
168+
results.extend([(p.id, p.display_name) for p in other_products])
169+
170+
return results
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from odoo import models, fields, api
2+
3+
4+
class ProductTemplate(models.Model):
5+
_inherit = 'product.template'
6+
7+
# Related fields to show recent invoice data for a product variant (read-only)
8+
last_invoice_date = fields.Date(
9+
string="Last Invoice Date",
10+
related='product_variant_id.last_invoice_date',
11+
store=False
12+
)
13+
14+
last_invoice_time_diff = fields.Char(
15+
string="Last Invoice Time Diff",
16+
related='product_variant_id.last_invoice_time_diff',
17+
store=False
18+
)
19+
20+
# Helper field to get the main variant of the template (used in searching/mapping)
21+
product_variant_id = fields.Many2one(
22+
'product.product',
23+
compute='_compute_product_variant_id',
24+
store=True,
25+
index=True
26+
)
27+
28+
@api.model
29+
def name_search(self, name="", args=None, operator="ilike", limit=100):
30+
args = args or []
31+
# Get customer/vendor ID from context
32+
partner_id = self.env.context.get('sale_order_partner_id') or self.env.context.get('purchase_order_partner_id')
33+
if not partner_id:
34+
return super().name_search(name, args, operator, limit)
35+
36+
# Fetch recent product interaction history based on context
37+
if self.env.context.get('sale_order_partner_id'):
38+
history_data = self.env['product.product']._get_recent_sales(partner_id)
39+
else:
40+
history_data = self.env['product.product']._get_recent_purchases(partner_id)
41+
42+
if not history_data:
43+
return super().name_search(name, args, operator, limit)
44+
45+
# Build a lookup of product ID to history entry
46+
history_map = {item['pid']: item for item in history_data}
47+
variant_ids = list(history_map.keys())
48+
# Find related product templates from those variants
49+
templates = self.env['product.product'].browse(variant_ids).mapped('product_tmpl_id')
50+
51+
# Prioritize templates based on history order
52+
domain = [('id', 'in', templates.ids), ('name', operator, name)] + args
53+
prioritized_templates = templates.search(domain).sorted(
54+
key=lambda t: min(
55+
[history_data.index(d) for d in history_data if d['pid'] in t.product_variant_ids.ids]
56+
or [len(history_data)]
57+
)
58+
)
59+
60+
results = []
61+
for tmpl in prioritized_templates:
62+
variant_id = tmpl.product_variant_id.id if tmpl.product_variant_ids else False
63+
item = history_map.get(variant_id)
64+
# Add time difference if available to display name
65+
time_str = self.env['product.product']._format_time_diff(item) if item else ""
66+
display_name = f"{tmpl.display_name}{time_str}" if time_str else tmpl.display_name
67+
results.append((tmpl.id, display_name))
68+
69+
# If results are fewer than limit, append others that matched but not in history
70+
other_limit = limit - len(results)
71+
if other_limit > 0:
72+
domain = [('id', 'not in', prioritized_templates.ids), ('name', operator, name)] + args
73+
other_templates = self.search(domain, limit=other_limit)
74+
results.extend([(t.id, t.display_name) for t in other_templates])
75+
76+
return results

0 commit comments

Comments
 (0)