Server side script report for sales analytics (#15589)

* Sales Analytics Report

* Codacy issue fixes and  name column addition

* Minor Fixes

* Minor Changes

* Codacy Issue Fixes

* Codacy Issue Fixes

* Bug Fixes

* Code cleaning and optimization

* Deleted Duplicate code

* Indentation Issue Fixes

* Added Supplier Condition

* Cleaned code and better function naming

* Added report link for sales analytics in selling.py

* fix(patch): Patch to delete old analytics reports

* feat(refactor): Created class and refactored code using object oriented paradigm

* Column condition fix

* Minor condition fix

* Minor fix

* parent child map for purchase analytics

* Minor Fixes in get_periodic_data

* Used dots for filters instead of brackets

* Minor Bug fix in get_period_date_ranges

* Test Cases for Analytics Report
This commit is contained in:
Deepesh Garg 2018-11-12 16:15:54 +05:30 committed by Nabin Hait
parent 35ec2469e9
commit 6714214951
7 changed files with 705 additions and 4 deletions

View File

@ -185,10 +185,10 @@ def get_data():
"icon": "fa fa-table", "icon": "fa fa-table",
"items": [ "items": [
{ {
"type": "page", "type": "report",
"name": "sales-analytics", "is_query_report": True,
"label": _("Sales Analytics"), "name": "Sales Analytics",
"icon": "fa fa-bar-chart", "doctype": "Sales Order"
}, },
{ {
"type": "page", "type": "page",

View File

@ -568,6 +568,10 @@ erpnext.patches.v11_0.remove_land_unit_icon
erpnext.patches.v11_0.add_default_dispatch_notification_template erpnext.patches.v11_0.add_default_dispatch_notification_template
erpnext.patches.v11_0.add_market_segments erpnext.patches.v11_0.add_market_segments
erpnext.patches.v11_0.add_sales_stages erpnext.patches.v11_0.add_sales_stages
execute:frappe.delete_doc("Page", "Sales Analytics")
execute:frappe.delete_doc("Page", "Purchase Analytics")
execute:frappe.delete_doc("Page", "Stock Analytics")
execute:frappe.delete_doc("Page", "Production Analytics")
erpnext.patches.v11_0.ewaybill_fields_gst_india erpnext.patches.v11_0.ewaybill_fields_gst_india
erpnext.patches.v11_0.drop_column_max_days_allowed erpnext.patches.v11_0.drop_column_max_days_allowed
erpnext.patches.v11_0.change_healthcare_desktop_icons erpnext.patches.v11_0.change_healthcare_desktop_icons

View File

@ -0,0 +1,129 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Sales Analytics"] = {
"filters": [
{
fieldname: "tree_type",
label: __("Tree Type"),
fieldtype: "Select",
options: ["Customer Group","Customer","Item Group","Item","Territory"],
default: "Customer",
reqd: 1
},
{
fieldname: "doc_type",
label: __("based_on"),
fieldtype: "Select",
options: ["Sales Order","Delivery Note","Sales Invoice"],
default: "Sales Invoice",
reqd: 1
},
{
fieldname: "value_quantity",
label: __("Value Or Qty"),
fieldtype: "Select",
options: [
{ "value": "Value", "label": __("Value") },
{ "value": "Quantity", "label": __("Quantity") },
],
default: "Value",
reqd: 1
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.defaults.get_user_default("year_start_date"),
reqd: 1
},
{
fieldname:"to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.defaults.get_user_default("year_end_date"),
reqd: 1
},
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1
},
{
fieldname: "range",
label: __("Range"),
fieldtype: "Select",
options: [
{ "value": "Weekly", "label": __("Weekly") },
{ "value": "Monthly", "label": __("Monthly") },
{ "value": "Quarterly", "label": __("Quarterly") },
{ "value": "Yearly", "label": __("Yearly") }
],
default: "Monthly",
reqd: 1
}
],
"formatter": function(value, row, column, data) {
if(!value){
value = 0
}
return value;
},
get_datatable_options(options) {
return Object.assign(options, {
checkboxColumn: true,
events: {
onCheckRow: function(data) {
row_name = data[2].content;
row_values = data.slice(5).map(function (column) {
return column.content;
})
entry = {
'name':row_name,
'values':row_values
}
let raw_data = frappe.query_report.chart.data;
let new_datasets = raw_data.datasets;
var found = false;
for(var i=0; i < new_datasets.length;i++){
if(new_datasets[i].name == row_name){
found = true;
new_datasets.splice(i,1);
break;
}
}
if(!found){
new_datasets.push(entry);
}
let new_data = {
labels: raw_data.labels,
datasets: new_datasets
}
setTimeout(() => {
frappe.query_report.chart.update(new_data)
},200)
setTimeout(() => {
frappe.query_report.chart.draw(true);
}, 800)
frappe.query_report.raw_chart_data = new_data;
},
}
})
},
}

View File

@ -0,0 +1,32 @@
{
"add_total_row": 0,
"creation": "2018-09-21 12:46:29.451048",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2018-09-21 12:46:29.451048",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Analytics",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Order",
"report_name": "Sales Analytics",
"report_type": "Script Report",
"roles": [
{
"role": "Stock User"
},
{
"role": "Maintenance User"
},
{
"role": "Accounts User"
},
{
"role": "Sales Manager"
}
]
}

View File

@ -0,0 +1,286 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe import _, scrub
from frappe.utils import getdate, flt
from six import iteritems
from erpnext.accounts.utils import get_fiscal_year
def execute(filters=None):
return Analytics(filters).run()
class Analytics(object):
def __init__(self, filters=None):
self.filters = frappe._dict(filters or {})
self.date_field = 'transaction_date' \
if self.filters.doc_type in ['Sales Order', 'Purchase Order'] else 'posting_date'
self.months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
self.get_period_date_ranges()
def run(self):
self.get_columns()
self.get_data()
self.get_chart_data()
return self.columns, self.data , None, self.chart
def get_columns(self):
self.columns =[{
"label": _(self.filters.tree_type + " ID"),
"options": self.filters.tree_type,
"fieldname": "entity",
"fieldtype": "Link",
"width": 140
}]
if self.filters.tree_type in ["Customer", "Supplier", "Item"]:
self.columns.append({
"label": _(self.filters.tree_type + " Name"),
"fieldname": "entity_name",
"fieldtype": "Data",
"width": 140
})
for dummy, end_date in self.periodic_daterange:
period = self.get_period(end_date)
self.columns.append({
"label": _(period),
"fieldname": scrub(period),
"fieldtype": "Float",
"width": 120
})
self.columns.append({
"label": _("Total"),
"fieldname": "total",
"fieldtype": "Float",
"width": 120
})
def get_data(self):
if self.filters.tree_type in ["Customer", "Supplier"]:
self.get_sales_transactions_based_on_customers_or_suppliers()
self.get_rows()
elif self.filters.tree_type == 'Item':
self.get_sales_transactions_based_on_items()
self.get_rows()
elif self.filters.tree_type in ["Customer Group", "Supplier Group", "Territory"]:
self.get_sales_transactions_based_on_customer_or_territory_group()
self.get_rows_by_group()
elif self.filters.tree_type == 'Item Group':
self.get_sales_transactions_based_on_item_group()
self.get_rows_by_group()
def get_sales_transactions_based_on_customers_or_suppliers(self):
if self.filters["value_quantity"] == 'Value':
value_field = "base_net_total as value_field"
else:
value_field = "total_qty as value_field"
if self.filters.tree_type == 'Customer':
entity = "customer as entity"
entity_name = "customer_name as entity_name"
else:
entity = "supplier as entity"
entity_name = "supplier_name as entity_name"
self.entries = frappe.get_all(self.filters.doc_type,
fields=[entity, entity_name, value_field, self.date_field],
filters = {
"docstatus": 1,
"company": self.filters.company,
self.date_field: ('between', [self.filters.from_date, self.filters.to_date])
}
)
self.entity_names = {}
for d in self.entries:
self.entity_names.setdefault(d.entity, d.entity_name)
def get_sales_transactions_based_on_items(self):
if self.filters["value_quantity"] == 'Value':
value_field = 'base_amount'
else:
value_field = 'qty'
self.entries = frappe.db.sql("""
select i.item_code as entity, i.item_name as entity_name, i.{value_field} as value_field, s.{date_field}
from `tab{doctype} Item` i , `tab{doctype}` s
where s.name = i.parent and i.docstatus = 1 and s.company = %s
and s.{date_field} between %s and %s
"""
.format(date_field=self.date_field, value_field = value_field, doctype=self.filters.doc_type),
(self.filters.company, self.filters.from_date, self.filters.to_date), as_dict=1)
self.entity_names = {}
for d in self.entries:
self.entity_names.setdefault(d.entity, d.entity_name)
def get_sales_transactions_based_on_customer_or_territory_group(self):
if self.filters["value_quantity"] == 'Value':
value_field = "base_net_total as value_field"
else:
value_field = "total_qty as value_field"
if self.filters.tree_type == 'Customer Group':
entity_field = 'customer_group as entity'
elif self.filters.tree_type == 'Supplier Group':
entity_field = "supplier as entity"
self.get_supplier_parent_child_map()
else:
entity_field = "territory as entity"
self.entries = frappe.get_all(self.filters.doc_type,
fields=[entity_field, value_field, self.date_field],
filters = {
"docstatus": 1,
"company": self.filters.company,
self.date_field: ('between', [self.filters.from_date, self.filters.to_date])
}
)
self.get_groups()
def get_sales_transactions_based_on_item_group(self):
if self.filters["value_quantity"] == 'Value':
value_field = "base_amount"
else:
value_field = "qty"
self.entries = frappe.db.sql("""
select i.item_group as entity, i.{value_field} as value_field, s.{date_field}
from `tab{doctype} Item` i , `tab{doctype}` s
where s.name = i.parent and i.docstatus = 1 and s.company = %s
and s.{date_field} between %s and %s
""".format(date_field=self.date_field, value_field = value_field, doctype=self.filters.doc_type),
(self.filters.company, self.filters.from_date, self.filters.to_date), as_dict=1)
self.get_groups()
def get_rows(self):
self.data=[]
self.get_periodic_data()
for entity, period_data in iteritems(self.entity_periodic_data):
row = {
"entity": entity,
"entity_name": self.entity_names.get(entity)
}
total = 0
for dummy, end_date in self.periodic_daterange:
period = self.get_period(end_date)
amount = flt(period_data.get(period, 0.0))
row[scrub(period)] = amount
total += amount
row["total"] = total
self.data.append(row)
def get_rows_by_group(self):
self.get_periodic_data()
out = []
for d in reversed(self.group_entries):
row = {
"entity": d.name,
"indent": self.depth_map.get(d.name)
}
total = 0
for dummy, end_date in self.periodic_daterange:
period = self.get_period(end_date)
amount = flt(self.entity_periodic_data.get(d.name, {}).get(period, 0.0))
row[scrub(period)] = amount
if d.parent:
self.entity_periodic_data.setdefault(d.parent, frappe._dict()).setdefault(period, 0.0)
self.entity_periodic_data[d.parent][period] += amount
total += amount
row["total"] = total
out = [row] + out
self.data = out
def get_periodic_data(self):
self.entity_periodic_data = frappe._dict()
for d in self.entries:
if self.filters.tree_type == "Supplier Group":
d.entity = self.parent_child_map.get(d.entity)
period = self.get_period(d.get(self.date_field))
self.entity_periodic_data.setdefault(d.entity, frappe._dict()).setdefault(period, 0.0)
self.entity_periodic_data[d.entity][period] += flt(d.value_field)
def get_period(self, posting_date):
if self.filters.range == 'Weekly':
period = "Week " + str(posting_date.isocalendar()[1])
elif self.filters.range == 'Monthly':
period = self.months[posting_date.month - 1]
elif self.filters.range == 'Quarterly':
period = "Quarter " + str(((posting_date.month-1)//3)+1)
else:
year = get_fiscal_year(posting_date, company=self.filters.company)
period = str(year[2])
return period
def get_period_date_ranges(self):
from dateutil.relativedelta import relativedelta
from_date, to_date = getdate(self.filters.from_date), getdate(self.filters.to_date)
increment = {
"Monthly": 1,
"Quarterly": 3,
"Half-Yearly": 6,
"Yearly": 12
}.get(self.filters.range, 1)
self.periodic_daterange = []
for dummy in range(1, 53, increment):
if self.filters.range == "Weekly":
period_end_date = from_date + relativedelta(days=6)
else:
period_end_date = from_date + relativedelta(months=increment, days=-1)
if period_end_date > to_date:
period_end_date = to_date
self.periodic_daterange.append([from_date, period_end_date])
from_date = period_end_date + relativedelta(days=1)
if period_end_date == to_date:
break
def get_groups(self):
if self.filters.tree_type == "Territory":
parent = 'parent_territory'
if self.filters.tree_type == "Customer Group":
parent = 'parent_customer_group'
if self.filters.tree_type == "Item Group":
parent = 'parent_item_group'
if self.filters.tree_type == "Supplier Group":
parent = 'parent_supplier_group'
self.depth_map = frappe._dict()
self.group_entries = frappe.db.sql("""select name, lft, rgt , {parent} as parent
from `tab{tree}` order by lft"""
.format(tree=self.filters.tree_type, parent=parent), as_dict=1)
for d in self.group_entries:
if d.parent:
self.depth_map.setdefault(d.name, self.depth_map.get(d.parent) + 1)
else:
self.depth_map.setdefault(d.name, 0)
def get_supplier_parent_child_map(self):
self.parent_child_map = frappe._dict(frappe.db.sql(""" select name, supplier_group from `tabSupplier`"""))
def get_chart_data(self):
labels = [d.get("label") for d in self.columns[3:]]
self.chart = {
"data": {
'labels': labels,
'datasets':[
]
},
"type": "line"
}

View File

@ -0,0 +1,250 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
import frappe.defaults
import unittest
from erpnext.selling.report.sales_analytics.sales_analytics import execute
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestAnalytics(unittest.TestCase):
def tearDown(self):
frappe.db.sql(""" DELETE FROM `tabSales Order` """)
def test_by_entity(self):
create_sales_order()
filters = {
'doc_type': 'Sales Order',
'range': 'Monthly',
'to_date': '2018-03-31',
'tree_type': 'Customer',
'company': '_Test Company',
'from_date': '2017-04-01',
'value_quantity': 'Value'
}
report = execute(filters)
expected_data = [
{
"entity": "_Test Customer 1",
"entity_name": "_Test Customer 1",
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 2000.0,
"mar": 0.0,
"total":2000.0
},
{
"entity": "_Test Customer 3",
"entity_name": "_Test Customer 3",
"apr": 0.0,
"may": 0.0,
"jun": 2000.0,
"jul": 1000.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total": 3000.0
},
{
"entity": "_Test Customer 2",
"entity_name": "_Test Customer 2",
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 1500.0,
"oct": 1000.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total":2500.0
}
]
self.assertEqual(expected_data, report[1])
def test_by_group(self):
create_sales_order()
filters = {
'doc_type': 'Sales Order',
'range': 'Monthly',
'to_date': '2018-03-31',
'tree_type': 'Customer Group',
'company': '_Test Company',
'from_date': '2017-04-01',
'value_quantity': 'Value'
}
report = execute(filters)
expected_data = [
{
"entity": "All Customer Groups",
"indent": 0,
"apr": 0.0,
"may": 0.0,
"jun": 2000.0,
"jul": 1000.0,
"aug": 0.0,
"sep": 1500.0,
"oct": 1000.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 2000.0,
"mar": 0.0,
"total":7500.0
},
{
"entity": "Individual",
"indent": 1,
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total": 0.0
},
{
"entity": "_Test Customer Group",
"indent": 1,
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total":0.0
},
{
"entity": "_Test Customer Group 1",
"indent": 1,
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total":0.0
}
]
self.assertEqual(expected_data, report[1])
def test_by_quantity(self):
create_sales_order()
filters = {
'doc_type': 'Sales Order',
'range': 'Monthly',
'to_date': '2018-03-31',
'tree_type': 'Customer',
'company': '_Test Company',
'from_date': '2017-04-01',
'value_quantity': 'Quantity'
}
report = execute(filters)
expected_data = [
{
"entity": "_Test Customer 1",
"entity_name": "_Test Customer 1",
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 20.0,
"mar": 0.0,
"total":20.0
},
{
"entity": "_Test Customer 3",
"entity_name": "_Test Customer 3",
"apr": 0.0,
"may": 0.0,
"jun": 20.0,
"jul": 10.0,
"aug": 0.0,
"sep": 0.0,
"oct": 0.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total": 30.0
},
{
"entity": "_Test Customer 2",
"entity_name": "_Test Customer 2",
"apr": 0.0,
"may": 0.0,
"jun": 0.0,
"jul": 0.0,
"aug": 0.0,
"sep": 15.0,
"oct": 10.0,
"nov": 0.0,
"dec": 0.0,
"jan": 0.0,
"feb": 0.0,
"mar": 0.0,
"total":25.0
}
]
self.assertEqual(expected_data, report[1])
def create_sales_order():
frappe.set_user("Administrator")
make_sales_order(qty=10, customer = "_Test Customer 1", transaction_date='2018-02-10')
make_sales_order(qty=10, customer = "_Test Customer 1", transaction_date='2018-02-15')
make_sales_order(qty=15, customer = "_Test Customer 2", transaction_date='2017-09-23')
make_sales_order(qty=10, customer = "_Test Customer 2", transaction_date='2017-10-10')
make_sales_order(qty=20, customer = "_Test Customer 3", transaction_date='2017-06-15')
make_sales_order(qty=10, customer = "_Test Customer 3", transaction_date='2017-07-10')