Skip to content

Commit 6464a09

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 7fe9db1 commit 6464a09

File tree

15 files changed

+698
-1
lines changed

15 files changed

+698
-1
lines changed

addons/sale/models/sale_order.py

Lines changed: 35 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 ===#
@@ -528,6 +533,36 @@ def _compute_amounts(self):
528533
order.amount_tax = tax_totals['tax_amount_currency']
529534
order.amount_total = tax_totals['total_amount_currency']
530535

536+
@api.depends(
537+
'order_line.qty_delivered',
538+
'order_line.product_uom_qty',
539+
)
540+
def _compute_is_unfulfilled(self):
541+
for order in self:
542+
order.is_unfulfilled = (
543+
any(
544+
line.qty_delivered < line.product_uom_qty
545+
for line in order.order_line
546+
)
547+
and order.state == 'sale'
548+
)
549+
550+
def _search_is_unfulfilled(self, operator, value):
551+
if operator not in ('=', '!='):
552+
return NotImplemented
553+
554+
operator = 'any' if operator == '=' else 'not any'
555+
556+
line_domain = Domain.custom(
557+
to_sql=lambda model, alias, query: SQL(
558+
'%s < %s',
559+
model._field_to_sql(alias, 'qty_delivered', query),
560+
model._field_to_sql(alias, 'product_uom_qty', query),
561+
),
562+
)
563+
564+
return Domain('state', '=', 'sale') & Domain('order_line', operator, line_domain)
565+
531566
def _add_base_lines_for_early_payment_discount(self):
532567
"""
533568
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
@@ -157,6 +157,8 @@
157157
'website_sale/static/src/js/tours/website_sale_shop.js',
158158
'website_sale/static/src/xml/website_sale.xml',
159159
'website_sale/static/src/scss/kanban_record.scss',
160+
'website_sale/static/src/js/website_sale_dashboard/**/*',
161+
'website_sale/static/src/views/**/*',
160162
],
161163
'website.website_builder_assets': [
162164
'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,
@@ -899,3 +899,150 @@ def _recompute_cart(self):
899899
"""Recompute taxes and prices for the current cart."""
900900
self._recompute_taxes()
901901
self._recompute_prices()
902+
903+
@api.model
904+
def retrieve_dashboard(self, period):
905+
"""
906+
Retrieve eCommerce dashboard statistics for a given period.
907+
908+
The data includes both period-based figures (total visitors, total sales, total orders)
909+
and global order counts (to fulfill, to confirm, to invoice).
910+
911+
:param str period: Identifier for the selected time period.
912+
:return: A dictionary containing dashboard statistics.
913+
:rtype: dict
914+
"""
915+
# Shape of the return value
916+
matrix = {
917+
'current_period': {
918+
'total_visitors': 0,
919+
'total_sales': 0.0,
920+
'total_orders': 0,
921+
},
922+
'period_gain': {
923+
'total_visitors': 0,
924+
'total_sales': 0,
925+
'total_orders': 0,
926+
},
927+
'overall': {
928+
'to_fulfill': 0,
929+
'to_confirm': 0,
930+
'to_invoice': 0,
931+
},
932+
}
933+
934+
# Build domain for the given period
935+
ecommerce_orders_domain = Domain('website_id', '!=', False) & Domain('state', '=', 'sale')
936+
order_period_domain = self._get_period_domain(period, 'date_order') & ecommerce_orders_domain
937+
order_previous_period_domain = self._get_previous_period_domain(period, 'date_order') & ecommerce_orders_domain
938+
visitor_period_domain = self._get_period_domain(period, 'last_connection_datetime')
939+
visitor_previous_period_domain = self._get_previous_period_domain(period, 'last_connection_datetime')
940+
941+
if order_period_domain or visitor_period_domain or order_previous_period_domain or visitor_previous_period_domain:
942+
# Compute period-based figures
943+
current_period_order_data = self._get_orders_period_data(order_period_domain)
944+
current_period_visitor_data = self._get_visitors_period_data(visitor_period_domain)
945+
matrix['current_period']['total_sales'] = current_period_order_data['total_sales']
946+
matrix['current_period']['total_orders'] = current_period_order_data['total_orders']
947+
matrix['current_period']['total_visitors'] = current_period_visitor_data['total_visitors']
948+
949+
previous_period_order_data = self._get_orders_period_data(order_previous_period_domain)
950+
previous_period_visitor_data = self._get_visitors_period_data(visitor_previous_period_domain)
951+
matrix['period_gain']['total_sales'] = (
952+
round(
953+
(
954+
(
955+
current_period_order_data['total_sales']
956+
- previous_period_order_data['total_sales']
957+
)
958+
/ previous_period_order_data['total_sales']
959+
)
960+
* 100
961+
)
962+
if previous_period_order_data.get('total_sales')
963+
else None
964+
)
965+
matrix['period_gain']['total_orders'] = (
966+
round(
967+
(
968+
(
969+
current_period_order_data['total_orders']
970+
- previous_period_order_data['total_orders']
971+
)
972+
/ previous_period_order_data['total_orders']
973+
)
974+
* 100
975+
)
976+
if previous_period_order_data.get('total_orders')
977+
else None
978+
)
979+
matrix['period_gain']['total_visitors'] = (
980+
round(
981+
(
982+
(
983+
current_period_visitor_data['total_visitors']
984+
- previous_period_visitor_data['total_visitors']
985+
)
986+
/ previous_period_visitor_data['total_visitors']
987+
)
988+
* 100
989+
)
990+
if previous_period_visitor_data.get('total_visitors')
991+
else None
992+
)
993+
994+
# Compute overall counts
995+
matrix['overall']['to_fulfill'] = self.search_count(
996+
Domain('is_unfulfilled', '=', True) & ecommerce_orders_domain,
997+
)
998+
matrix['overall']['to_confirm'] = self.search_count(
999+
Domain('state', '=', 'sent') & Domain('website_id', '!=', False),
1000+
)
1001+
matrix['overall']['to_invoice'] = self.search_count(
1002+
Domain('invoice_status', '=', 'to invoice') & ecommerce_orders_domain,
1003+
)
1004+
1005+
return matrix
1006+
1007+
def _get_period_domain(self, period, field):
1008+
if period == 'last_7_days':
1009+
return Domain(field, '>', 'today -7d +1d')
1010+
if period == 'last_30_days':
1011+
return Domain(field, '>', 'today -30d +1d')
1012+
if period == 'last_90_days':
1013+
return Domain(field, '>', 'today -90d +1d')
1014+
if period == 'last_365_days':
1015+
return Domain(field, '>', 'today -365d +1d')
1016+
1017+
return False
1018+
1019+
def _get_previous_period_domain(self, period, field):
1020+
if period == 'last_7_days':
1021+
return Domain(field, '>', 'today -14d +1d') & Domain(field, '<', 'today -7d +1d')
1022+
if period == 'last_30_days':
1023+
return Domain(field, '>', 'today -60d +1d') & Domain(field, '<', 'today -30d +1d')
1024+
if period == 'last_90_days':
1025+
return Domain(field, '>', 'today -180d +1d') & Domain(field, '<', 'today -90d +1d')
1026+
if period == 'last_365_days':
1027+
return Domain(field, '>', 'today -730d +1d') & Domain(field, '<', 'today -365d +1d')
1028+
1029+
return False
1030+
1031+
def _get_orders_period_data(self, period_domain):
1032+
aggregated_order_data = self._read_group(
1033+
domain=period_domain,
1034+
aggregates=['amount_total:sum', '__count'],
1035+
)
1036+
total_sales, total_orders = aggregated_order_data[0]
1037+
1038+
return {
1039+
'total_sales': float_round(total_sales, precision_rounding=0.01),
1040+
'total_orders': total_orders,
1041+
}
1042+
1043+
def _get_visitors_period_data(self, period_domain):
1044+
visitor_count = self.env['website.visitor'].search_count(period_domain)
1045+
1046+
return {
1047+
'total_visitors': visitor_count,
1048+
}
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)