Skip to content

Commit 5cdfc02

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 5cdfc02

File tree

9 files changed

+427
-0
lines changed

9 files changed

+427
-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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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+
}
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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from odoo import models, fields, api
2+
3+
4+
class ProductProduct(models.Model):
5+
_inherit = 'product.product'
6+
7+
last_invoice_date = fields.Date(
8+
string="Last Invoice Date",
9+
compute="_compute_last_invoice_data",
10+
store=False
11+
)
12+
last_invoice_time_diff = fields.Char(
13+
string="Last Invoice Time Diff",
14+
compute="_compute_last_invoice_data",
15+
store=False
16+
)
17+
18+
def _compute_last_invoice_data(self):
19+
for product in self:
20+
partner_id = product.env.context.get('sale_order_partner_id') \
21+
or product.env.context.get('purchase_order_partner_id')
22+
23+
is_sale = bool(product.env.context.get('sale_order_partner_id'))
24+
move_type = 'out_invoice' if is_sale else 'in_invoice'
25+
26+
domain = [
27+
('state', '=', 'posted'),
28+
('invoice_date', '!=', False),
29+
('line_ids.product_id', '=', product.id),
30+
('move_type', '=', move_type),
31+
]
32+
if partner_id:
33+
domain.append(('partner_id', '=', partner_id))
34+
35+
move = product.env['account.move'].search(
36+
domain, order='invoice_date desc', limit=1
37+
)
38+
39+
product.last_invoice_date = move.invoice_date if move else False
40+
product.last_invoice_time_diff = (
41+
self._format_time_diff(move.invoice_date) if move else False
42+
)
43+
44+
@api.model
45+
def _get_recent_invoices(self, partner_id, is_sale=True):
46+
if not partner_id:
47+
return []
48+
49+
move_type = 'out_invoice' if is_sale else 'in_invoice'
50+
moves = self.env['account.move'].search([
51+
('partner_id', '=', partner_id),
52+
('move_type', '=', move_type),
53+
('state', '=', 'posted'),
54+
('invoice_date', '!=', False)
55+
], order='invoice_date desc')
56+
57+
recent, seen = [], set()
58+
for mv in moves:
59+
for line in mv.line_ids.filtered('product_id'):
60+
pid = line.product_id.id
61+
if pid not in seen:
62+
recent.append({'pid': pid, 'date': mv.invoice_date})
63+
seen.add(pid)
64+
return recent
65+
66+
@api.model
67+
def _format_time_diff(self, invoice_date):
68+
if not invoice_date:
69+
return ""
70+
days = (fields.Date.today() - invoice_date).days
71+
if days > 365:
72+
return f"{days // 365}y ago"
73+
if days > 30:
74+
return f"{days // 30}mo ago"
75+
if days > 7:
76+
return f"{days // 7}w ago"
77+
if days > 0:
78+
return f"{days}d ago"
79+
return "today"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from odoo import models, fields, api
2+
3+
4+
class ProductTemplate(models.Model):
5+
_inherit = 'product.template'
6+
7+
last_invoice_date = fields.Date(
8+
string="Last Invoice Date",
9+
related='product_variant_id.last_invoice_date',
10+
store=False
11+
)
12+
last_invoice_time_diff = fields.Char(
13+
string="Last Invoice Time Diff",
14+
related='product_variant_id.last_invoice_time_diff',
15+
store=False
16+
)
17+
18+
product_variant_id = fields.Many2one(
19+
'product.product',
20+
compute='_compute_product_variant_id',
21+
store=True,
22+
index=True
23+
)
24+
25+
@api.model
26+
def name_search(self, name="", args=None, operator="ilike", limit=100):
27+
args = args or []
28+
29+
partner_id = self.env.context.get('sale_order_partner_id') \
30+
or self.env.context.get('purchase_order_partner_id')
31+
if not partner_id:
32+
return super().name_search(name, args, operator, limit)
33+
34+
is_sale = bool(self.env.context.get('sale_order_partner_id'))
35+
recent_lines = self.env['product.product']._get_recent_invoices(
36+
partner_id=partner_id,
37+
is_sale=is_sale
38+
)
39+
40+
if not recent_lines:
41+
return super().name_search(name, args, operator, limit)
42+
43+
recent_template_ids = list(dict.fromkeys(
44+
self.env['product.product'].browse(rl['pid']).product_tmpl_id.id
45+
for rl in recent_lines
46+
))
47+
48+
base_domain = [('name', operator, name)] + args
49+
50+
recent_templates = self.search(
51+
[('id', 'in', recent_template_ids)] + base_domain,
52+
limit=limit
53+
)
54+
other_templates = self.search(
55+
[('id', 'not in', recent_template_ids)] + base_domain,
56+
limit=max(0, limit - len(recent_templates))
57+
)
58+
59+
results = []
60+
for tmpl_id in recent_template_ids:
61+
tmpl = recent_templates.filtered(lambda t: t.id == tmpl_id)
62+
if tmpl:
63+
td = tmpl.last_invoice_time_diff
64+
label = f"{tmpl.display_name}{td}" if td else tmpl.display_name
65+
results.append((tmpl.id, label))
66+
67+
results.extend((t.id, t.display_name) for t in other_templates)
68+
return results
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from odoo import models, fields, api
2+
from datetime import datetime
3+
4+
5+
class SaleOrderLine(models.Model):
6+
_inherit = 'sale.order.line'
7+
8+
# Computed stock-related fields to be shown per product line
9+
forecasted_qty = fields.Float(string="Forecasted Qty", compute='_compute_forecasted_qty', store=False)
10+
qty_on_hand = fields.Float(string="On Hand Qty", compute='_compute_qty_on_hand', store=False)
11+
qty_difference = fields.Float(string="Qty Difference", compute='_compute_qty_difference', store=False)
12+
13+
@api.depends('product_id')
14+
def _compute_forecasted_qty(self):
15+
"""Fetches forecasted quantity (virtual stock) for selected product."""
16+
for line in self:
17+
if line.product_id:
18+
line.forecasted_qty = line.product_id.virtual_available
19+
else:
20+
line.forecasted_qty = 0.0
21+
22+
@api.depends('product_id')
23+
def _compute_qty_on_hand(self):
24+
"""Fetches current on-hand quantity for selected product."""
25+
for line in self:
26+
if line.product_id:
27+
line.qty_on_hand = line.product_id.qty_available
28+
else:
29+
line.qty_on_hand = 0.0
30+
31+
@api.depends('forecasted_qty', 'qty_on_hand')
32+
def _compute_qty_difference(self):
33+
"""Computes difference between forecasted and on-hand quantity."""
34+
for line in self:
35+
line.qty_difference = line.forecasted_qty - line.qty_on_hand
36+
37+
38+
# Inherit SaleOrder to display last sold products for a customer
39+
class SaleOrder(models.Model):
40+
_inherit = 'sale.order'
41+
42+
# Computed list of recent products sold to the selected customer
43+
last_sold_products = fields.Many2many(
44+
'product.product',
45+
compute='_compute_last_sold_products',
46+
string="Last 5 Sold Products"
47+
)
48+
49+
# Last invoice date for the selected customer
50+
last_invoice_date = fields.Date(
51+
compute='_compute_last_invoice_date',
52+
string="Last Invoice Date"
53+
)
54+
55+
# Textual info summary of last sold products and invoice timestamps
56+
last_sold_products_info = fields.Text(
57+
compute='_compute_last_sold_products_info',
58+
string="Last Sold Products Info"
59+
)
60+
61+
# Used to conditionally hide info block if there's no history
62+
invisible_last_sold_info = fields.Boolean(
63+
compute='_compute_invisible_last_sold_info',
64+
string="Hide Last Sold Info"
65+
)
66+
67+
@api.depends('partner_id')
68+
def _compute_last_sold_products(self):
69+
"""
70+
Retrieves unique products from posted customer invoices,
71+
ordered by invoice date for selected partner.
72+
"""
73+
for order in self:
74+
if not order.partner_id:
75+
order.last_sold_products = [(5, 0, 0)]
76+
continue
77+
78+
# Search for invoice lines with products for the customer
79+
lines = self.env['account.move.line'].search([
80+
('move_id.partner_id', '=', order.partner_id.id),
81+
('move_id.move_type', '=', 'out_invoice'),
82+
('move_id.state', '=', 'posted'),
83+
('display_type', '=', 'product'),
84+
('product_id', '!=', False)
85+
], order='date desc, id desc')
86+
87+
# Deduplicate products to keep the latest 5 sold ones
88+
product_ids_ordered = []
89+
seen_products = set()
90+
for line in lines:
91+
if line.product_id.id not in seen_products:
92+
product_ids_ordered.append(line.product_id.id)
93+
seen_products.add(line.product_id.id)
94+
95+
order.last_sold_products = [(6, 0, product_ids_ordered)]
96+
97+
@api.depends('partner_id')
98+
def _compute_last_invoice_date(self):
99+
"""
100+
Finds the most recent invoice date for this customer.
101+
"""
102+
for order in self:
103+
if not order.partner_id:
104+
order.last_invoice_date = False
105+
continue
106+
107+
last_invoice = self.env['account.move'].search([
108+
('partner_id', '=', order.partner_id.id),
109+
('move_type', '=', 'out_invoice'),
110+
('state', '=', 'posted'),
111+
('invoice_date', '!=', False)
112+
], order='invoice_date desc', limit=1)
113+
114+
order.last_invoice_date = last_invoice.invoice_date if last_invoice else False
115+
116+
@api.depends('last_sold_products', 'partner_id')
117+
def _compute_last_sold_products_info(self):
118+
"""
119+
Generates readable lines like:
120+
• Product A (Invoiced 3 days ago on 2024-06-15)
121+
"""
122+
for order in self:
123+
if not order.last_sold_products:
124+
order.last_sold_products_info = "No recent products found for this customer."
125+
continue
126+
127+
product_ids = order.last_sold_products.ids
128+
last_invoice_dates = {}
129+
130+
# Find invoice dates per product
131+
all_lines = self.env['account.move.line'].search([
132+
('move_id.partner_id', '=', order.partner_id.id),
133+
('product_id', 'in', product_ids),
134+
('move_id.move_type', '=', 'out_invoice'),
135+
('move_id.state', '=', 'posted'),
136+
('move_id.invoice_date', '!=', False)
137+
])
138+
139+
for line in all_lines:
140+
product_id = line.product_id.id
141+
invoice_date = line.move_id.invoice_date
142+
if product_id not in last_invoice_dates or invoice_date > last_invoice_dates.get(product_id):
143+
last_invoice_dates[product_id] = invoice_date
144+
145+
info_lines = []
146+
current_dt = fields.Datetime.now()
147+
148+
for product in order.last_sold_products:
149+
last_date = last_invoice_dates.get(product.id)
150+
if last_date:
151+
# Time difference from now to last invoice date
152+
invoice_dt = datetime.combine(last_date, datetime.min.time())
153+
time_diff = current_dt - invoice_dt
154+
days = time_diff.days
155+
156+
if days > 1:
157+
time_str = f"{days} days ago"
158+
elif days == 1:
159+
time_str = "1 day ago"
160+
else:
161+
time_str = f"{time_diff.seconds // 3600} hours ago"
162+
163+
info_lines.append(f"• {product.display_name} (Invoiced {time_str} on {last_date.strftime('%Y-%m-%d')})")
164+
else:
165+
info_lines.append(f"• {product.display_name} (No recent invoice found)")
166+
167+
order.last_sold_products_info = "\n".join(info_lines)
168+
169+
@api.depends('last_sold_products')
170+
def _compute_invisible_last_sold_info(self):
171+
"""Boolean toggle: hide info block if no products available."""
172+
for order in self:
173+
order.invisible_last_sold_info = not bool(order.last_sold_products)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<record id="view_product_template_kanban_inherit" model="ir.ui.view">
4+
<field name="name">product.product.kanban.inherit.catalog</field>
5+
<field name="model">product.product</field>
6+
<field name="inherit_id" ref="product.product_view_kanban_catalog"/>
7+
<field name="arch" type="xml">
8+
<xpath expr="//div[contains(@name, 'o_kanban_qty_available')]" position="inside">
9+
<field name="virtual_available" invisible="1"/>
10+
<field name="qty_available" invisible="1"/> <!-- Ensure loaded -->
11+
<field name="last_invoice_date" invisible="1"/> <!-- Ensure loaded -->
12+
<field name="last_invoice_time_diff" invisible="1"/> <!-- Ensure loaded -->
13+
<t t-set="diff" t-value="record.virtual_available.raw_value - record.qty_available.raw_value"/>
14+
<t t-if="diff &gt; 0">
15+
<div style="color: green;">(+ <t t-esc="diff"/>)</div>
16+
</t>
17+
<t t-elif="diff &lt; 0">
18+
<div style="color: red;">(<t t-esc="diff"/>)</div>
19+
</t>
20+
<t t-else="">
21+
<div>(0)</div>
22+
</t>
23+
<!-- Display last invoice info -->
24+
<div t-if="record.last_invoice_time_diff.value" style="font-size: 80%; color: #888;">
25+
⏱ <t t-esc="record.last_invoice_time_diff.value"/>
26+
</div>
27+
<div t-if="record.last_invoice_date.value" style="font-size: 80%; color: #aaa;">
28+
📅 <t t-esc="record.last_invoice_date.value"/>
29+
</div>
30+
</xpath>
31+
</field>
32+
</record>
33+
</odoo>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<record id="view_order_form_inherit_custom" model="ir.ui.view">
4+
<field name="name">purchase.order.form.inherit.custom</field>
5+
<field name="model">purchase.order</field>
6+
<field name="inherit_id" ref="purchase.purchase_order_form"/>
7+
<field name="arch" type="xml">
8+
<xpath expr="//field[@name='order_line']/list/field[@name='product_id']" position="attributes">
9+
<attribute name="context">{'purchase_order_partner_id': parent.partner_id}</attribute>
10+
</xpath>
11+
<!-- <xpath expr="//field[@name='order_line']/list/field[@name='product_template_id']" position="attributes">
12+
<attribute name="context">{'purchase_order_partner_id': parent.partner_id}</attribute>
13+
</xpath> -->
14+
</field>
15+
</record>
16+
</odoo>

0 commit comments

Comments
 (0)