Skip to content

Commit 8a3d7df

Browse files
committed
[IMP] website_sale: add eCommerce dashboard
This commit brings back the eCommerce dashboard for admins, providing a quick overview of eCommerce sales performance. - Adds the dashboard in list view. - Implements custom search filters for relevant card data. - Adds a dropdown with predefined time periods to view sales, average cart value, and conversion rate. - Adds a new view for eCommerce orders as some extesions of main `sale` has no relevence in eCommerce context. task-5153059
1 parent df5e739 commit 8a3d7df

File tree

15 files changed

+700
-1
lines changed

15 files changed

+700
-1
lines changed

addons/sale/models/sale_order.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ def _rec_names_search(self):
325325
show_update_pricelist = fields.Boolean(
326326
string="Has Pricelist Changed", store=False) # True if the pricelist was changed
327327

328+
# filter related fields
329+
is_unfulfilled = fields.Boolean(
330+
string="Unfulfilled Orders", compute='_compute_is_unfulfilled', search='_search_is_unfulfilled',
331+
)
332+
328333
_date_order_id_idx = models.Index("(date_order desc, id desc)")
329334

330335
#=== COMPUTE METHODS ===#
@@ -523,6 +528,38 @@ def _compute_amounts(self):
523528
order.amount_tax = tax_totals['tax_amount_currency']
524529
order.amount_total = tax_totals['total_amount_currency']
525530

531+
@api.depends(
532+
'order_line.qty_delivered',
533+
'order_line.product_uom_qty',
534+
)
535+
def _compute_is_unfulfilled(self):
536+
for order in self:
537+
order.is_unfulfilled = (
538+
any(
539+
line.qty_delivered < line.product_uom_qty
540+
for line in order.order_line
541+
)
542+
and order.state == 'sale'
543+
)
544+
545+
def _search_is_unfulfilled(self, operator, value):
546+
if operator not in ('=', '!='):
547+
return NotImplemented
548+
549+
operator = 'in' if operator == '=' else 'not in'
550+
551+
# Fetch sale order IDs with at least one unfulfilled line
552+
self.env.cr.execute("""
553+
SELECT DISTINCT order_id
554+
FROM sale_order_line
555+
WHERE qty_delivered < product_uom_qty
556+
AND state = 'sale'
557+
""")
558+
order_ids = [row[0] for row in self.env.cr.fetchall()]
559+
560+
# Return a domain based on the boolean value
561+
return [('id', operator, order_ids)]
562+
526563
def _add_base_lines_for_early_payment_discount(self):
527564
"""
528565
When applying a payment term with an early payment discount, and when said payment term computes the tax on the

addons/website_sale/__manifest__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@
159159
'website_sale/static/src/js/tours/website_sale_shop.js',
160160
'website_sale/static/src/xml/website_sale.xml',
161161
'website_sale/static/src/scss/kanban_record.scss',
162+
'website_sale/static/src/js/website_sale_dashboard/**/*',
163+
'website_sale/static/src/views/**/*',
162164
],
163165
'website.website_builder_assets': [
164166
'website_sale/static/src/js/website_sale_form_editor.js',

addons/website_sale/models/sale_order.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from odoo.exceptions import UserError, ValidationError
1010
from odoo.fields import Command, Domain
1111
from odoo.http import request
12-
from odoo.tools import float_is_zero
12+
from odoo.tools import float_is_zero, float_round
1313

1414
from odoo.addons.website_sale.models.website import (
1515
FISCAL_POSITION_SESSION_CACHE_KEY,
@@ -892,3 +892,150 @@ def _recompute_cart(self):
892892
"""Recompute taxes and prices for the current cart."""
893893
self._recompute_taxes()
894894
self._recompute_prices()
895+
896+
@api.model
897+
def retrieve_dashboard(self, period):
898+
"""
899+
Retrieve eCommerce dashboard statistics for a given period.
900+
901+
The data includes both period-based figures (total visitors, total sales, total orders)
902+
and global order counts (to fulfill, to confirm, to invoice).
903+
904+
:param str period: Identifier for the selected time period.
905+
:return: A dictionary containing dashboard statistics.
906+
:rtype: dict
907+
"""
908+
# Shape of the return value
909+
matrix = {
910+
'current_period': {
911+
'total_visitors': 0,
912+
'total_sales': 0.0,
913+
'total_orders': 0,
914+
},
915+
'period_gain': {
916+
'total_visitors': 0,
917+
'total_sales': 0,
918+
'total_orders': 0,
919+
},
920+
'overall': {
921+
'to_fulfill': 0,
922+
'to_confirm': 0,
923+
'to_invoice': 0,
924+
},
925+
}
926+
927+
# Build domain for the given period
928+
ecommerce_orders_domain = Domain('website_id', '!=', False) & Domain('state', '=', 'sale')
929+
order_period_domain = self._get_period_domain(period, 'date_order') & ecommerce_orders_domain
930+
order_previous_period_domain = self._get_previous_period_domain(period, 'date_order') & ecommerce_orders_domain
931+
visitor_period_domain = self._get_period_domain(period, 'last_connection_datetime')
932+
visitor_previous_period_domain = self._get_previous_period_domain(period, 'last_connection_datetime')
933+
934+
if order_period_domain or visitor_period_domain or order_previous_period_domain or visitor_previous_period_domain:
935+
# Compute period-based figures
936+
current_period_order_data = self._get_orders_period_data(order_period_domain)
937+
current_period_visitor_data = self._get_visitors_period_data(visitor_period_domain)
938+
matrix['current_period']['total_sales'] = current_period_order_data['total_sales']
939+
matrix['current_period']['total_orders'] = current_period_order_data['total_orders']
940+
matrix['current_period']['total_visitors'] = current_period_visitor_data['total_visitors']
941+
942+
previous_period_order_data = self._get_orders_period_data(order_previous_period_domain)
943+
previous_period_visitor_data = self._get_visitors_period_data(visitor_previous_period_domain)
944+
matrix['period_gain']['total_sales'] = (
945+
round(
946+
(
947+
(
948+
current_period_order_data['total_sales']
949+
- previous_period_order_data['total_sales']
950+
)
951+
/ previous_period_order_data['total_sales']
952+
)
953+
* 100
954+
)
955+
if previous_period_order_data.get('total_sales')
956+
else None
957+
)
958+
matrix['period_gain']['total_orders'] = (
959+
round(
960+
(
961+
(
962+
current_period_order_data['total_orders']
963+
- previous_period_order_data['total_orders']
964+
)
965+
/ previous_period_order_data['total_orders']
966+
)
967+
* 100
968+
)
969+
if previous_period_order_data.get('total_orders')
970+
else None
971+
)
972+
matrix['period_gain']['total_visitors'] = (
973+
round(
974+
(
975+
(
976+
current_period_visitor_data['total_visitors']
977+
- previous_period_visitor_data['total_visitors']
978+
)
979+
/ previous_period_visitor_data['total_visitors']
980+
)
981+
* 100
982+
)
983+
if previous_period_visitor_data.get('total_visitors')
984+
else None
985+
)
986+
987+
# Compute overall counts
988+
matrix['overall']['to_fulfill'] = self.search_count(
989+
Domain('is_unfulfilled', '=', True) & ecommerce_orders_domain,
990+
)
991+
matrix['overall']['to_confirm'] = self.search_count(
992+
Domain('state', '=', 'sent') & Domain('website_id', '!=', False),
993+
)
994+
matrix['overall']['to_invoice'] = self.search_count(
995+
Domain('invoice_status', '=', 'to invoice') & ecommerce_orders_domain,
996+
)
997+
998+
return matrix
999+
1000+
def _get_period_domain(self, period, field):
1001+
if period == 'last_7_days':
1002+
return Domain(field, '>', 'today -7d +1d')
1003+
if period == 'last_30_days':
1004+
return Domain(field, '>', 'today -30d +1d')
1005+
if period == 'last_90_days':
1006+
return Domain(field, '>', 'today -90d +1d')
1007+
if period == 'last_365_days':
1008+
return Domain(field, '>', 'today -365d +1d')
1009+
1010+
return False
1011+
1012+
def _get_previous_period_domain(self, period, field):
1013+
if period == 'last_7_days':
1014+
return Domain(field, '>', 'today -14d +1d') & Domain(field, '<', 'today -7d +1d')
1015+
if period == 'last_30_days':
1016+
return Domain(field, '>', 'today -60d +1d') & Domain(field, '<', 'today -30d +1d')
1017+
if period == 'last_90_days':
1018+
return Domain(field, '>', 'today -180d +1d') & Domain(field, '<', 'today -90d +1d')
1019+
if period == 'last_365_days':
1020+
return Domain(field, '>', 'today -730d +1d') & Domain(field, '<', 'today -365d +1d')
1021+
1022+
return False
1023+
1024+
def _get_orders_period_data(self, period_domain):
1025+
aggregated_order_data = self._read_group(
1026+
domain=period_domain,
1027+
aggregates=['amount_total:sum', '__count'],
1028+
)
1029+
total_sales, total_orders = aggregated_order_data[0]
1030+
1031+
return {
1032+
'total_sales': float_round(total_sales, precision_rounding=0.01),
1033+
'total_orders': total_orders,
1034+
}
1035+
1036+
def _get_visitors_period_data(self, period_domain):
1037+
visitor_count = self.env['website.visitor'].search_count(period_domain)
1038+
1039+
return {
1040+
'total_visitors': visitor_count,
1041+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { _t } from '@web/core/l10n/translation';
2+
import { Component } from '@odoo/owl';
3+
import { Dropdown } from '@web/core/dropdown/dropdown';
4+
import { DropdownItem } from '@web/core/dropdown/dropdown_item';
5+
6+
export const DATE_OPTIONS = [
7+
{
8+
id: 'last_7_days',
9+
label: _t("Last 7 days"),
10+
},
11+
{
12+
id: 'last_30_days',
13+
label: _t("Last 30 days"),
14+
},
15+
{
16+
id: 'last_90_days',
17+
label: _t("Last 90 days"),
18+
},
19+
{
20+
id: 'last_365_days',
21+
label: _t("Last 365 days"),
22+
},
23+
];
24+
25+
export class DateFilterButton extends Component {
26+
static template = 'website_sale.DateFilterButton';
27+
static components = { Dropdown, DropdownItem };
28+
static props = {
29+
selectedFilter: {
30+
type: Object,
31+
optional: true,
32+
shape: {
33+
id: String,
34+
label: String,
35+
},
36+
},
37+
update: Function,
38+
};
39+
40+
get dateFilters() {
41+
return DATE_OPTIONS;
42+
}
43+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<templates>
3+
<t t-name="website_sale.DateFilterButton">
4+
<Dropdown navigationOptions="{ 'shouldFocusChildInput': false }">
5+
<button class="btn btn-secondary">
6+
<i class="fa fa-calendar me-2"/>
7+
<span t-esc="props.selectedFilter.label"/>
8+
</button>
9+
<t t-set-slot="content">
10+
<t t-foreach="dateFilters" t-as="filter" t-key="filter.id">
11+
<DropdownItem
12+
tag="'div'"
13+
class="{ 'selected': props.selectedFilter.id === filter.id, 'd-flex justify-content-between': true }"
14+
closingMode="'none'"
15+
onSelected="() => this.props.update(filter)"
16+
>
17+
<div t-esc="filter.label"/>
18+
</DropdownItem>
19+
</t>
20+
</t>
21+
</Dropdown>
22+
</t>
23+
</templates>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useService } from '@web/core/utils/hooks';
2+
import { Component, onWillStart, onWillUpdateProps, useState } from '@odoo/owl';
3+
import { DateFilterButton, DATE_OPTIONS } from './date_filter_button/date_filter_button';
4+
5+
export class WebsiteSaleDashboard extends Component {
6+
static template = 'website_sale.WebsiteSaleDashboard';
7+
static props = { list: { type: Object, optional: true } };
8+
static components = { DateFilterButton };
9+
10+
setup() {
11+
this.state = useState({
12+
eCommerceData: {},
13+
selectedFilter: DATE_OPTIONS[0],
14+
});
15+
this.orm = useService('orm');
16+
17+
onWillStart(async () => {
18+
await this.updateDashboardState();
19+
});
20+
onWillUpdateProps(async () => {
21+
await this.updateDashboardState();
22+
});
23+
}
24+
25+
async updateDashboardState(filter = false) {
26+
if (filter) {
27+
this.state.selectedFilter = filter;
28+
}
29+
this.state.eCommerceData = await this.orm.call('sale.order', 'retrieve_dashboard', [
30+
this.state.selectedFilter.id,
31+
]);
32+
}
33+
34+
/**
35+
* This method clears the current search query and activates
36+
* the filters found in `filter_name` attibute from card clicked
37+
*/
38+
setSearchContext(ev) {
39+
const filter_name = ev.currentTarget.getAttribute('filter_name');
40+
const filters = filter_name.split(',');
41+
const searchItems = this.env.searchModel.getSearchItems((item) =>
42+
filters.includes(item.name)
43+
);
44+
this.env.searchModel.query = [];
45+
for (const item of searchItems) {
46+
this.env.searchModel.toggleSearchItem(item.id);
47+
}
48+
}
49+
50+
getPeriodCardClass(dataName) {
51+
if (this.state.eCommerceData['period_gain'][dataName] > 0) {
52+
return 'text-success';
53+
} else if (this.state.eCommerceData['period_gain'][dataName] < 0) {
54+
return 'text-danger';
55+
}
56+
return 'text-muted';
57+
}
58+
}

0 commit comments

Comments
 (0)