feat: add run mode, add tests, various fixes
diff --git a/erpnext/accounts/report/tax_detail/tax_detail.js b/erpnext/accounts/report/tax_detail/tax_detail.js
index 894db9e..8cdce54 100644
--- a/erpnext/accounts/report/tax_detail/tax_detail.js
+++ b/erpnext/accounts/report/tax_detail/tax_detail.js
@@ -92,11 +92,8 @@
 			freeze: true
 		}).then((r) => {
 			const data = JSON.parse(r.message[report_name]['json']);
-			if (data && data['sections']) {
-				this.sections = data['sections'];
-			} else {
-				this.sections = {};
-			}
+			this.sections = data.sections || {};
+			this.controls['show_detail'].set_input(data.show_detail);
 			this.set_section();
 		})
 		this.loaded = 1;
@@ -107,8 +104,11 @@
 			args: {
 				reference_report: 'Tax Detail',
 				report_name: this.qr.report_name,
-				columns: this.qr.get_visible_columns(),
-				sections: this.sections
+				data: {
+					columns: this.qr.get_visible_columns(),
+					sections: this.sections,
+					show_detail: this.controls['show_detail'].get_input_value()
+				}
 			},
 			freeze: true
 		}).then((r) => {
@@ -233,7 +233,9 @@
 	reload() {
 		// Reloads the data. When the datatable is reloaded, load_report()
 		// will be run by the after_datatable_render event.
+		// TODO: why does this trigger multiple reloads?
 		this.qr.refresh();
+		this.show_help();
 		if (this.edit_mode()) {
 			this.reload_filter();
 		} else {
@@ -354,6 +356,12 @@
 				this.save_report();
 			}
 		});
+		controls['show_detail'] = this.page.add_field({
+			label: __('Show Detail'),
+			fieldtype: 'Check',
+			fieldname: 'show_detail',
+			default: 1
+		});
 		this.controls = controls;
 		this.set_value_options();
 		this.get_filter_controls();
diff --git a/erpnext/accounts/report/tax_detail/tax_detail.py b/erpnext/accounts/report/tax_detail/tax_detail.py
index 2ea782e..6bed898 100644
--- a/erpnext/accounts/report/tax_detail/tax_detail.py
+++ b/erpnext/accounts/report/tax_detail/tax_detail.py
@@ -54,10 +54,89 @@
 		order by ge.posting_date, ge.voucher_no
 		""".format(fieldstr=fieldstr), filters, as_dict=1)
 
-	gl_entries = modify_report_data(gl_entries)
+	report_data = modify_report_data(gl_entries)
+	summary = None
+	if filters['mode'] == 'run' and filters['report_name'] != 'Tax Detail':
+		report_data, summary = run_report(filters['report_name'], report_data)
 
-	return get_columns(fieldlist), gl_entries
+	# return columns, data, message, chart, report_summary
+	return get_columns(fieldlist), report_data, None, None, summary
 
+def run_report(report_name, data):
+	"Applies the sections and filters saved in the custom report"
+	report_config = json.loads(frappe.get_doc('Report', report_name).json)
+	# Columns indexed from 1 wrt colno
+	columns = report_config.get('columns')
+	sections = report_config.get('sections', {})
+	show_detail = report_config.get('show_detail', 1)
+	new_data = []
+	summary = []
+	for section_name, section in sections.items():
+		section_total = 0.0
+		for filt_name, filt in section.items():
+			value_field = filt['fieldname']
+			rmidxs = []
+			for colno, filter_string in filt['filters'].items():
+				filter_field = columns[int(colno) - 1]['fieldname']
+				for i, row in enumerate(data):
+					if not filter_match(row[filter_field], filter_string):
+						rmidxs += [i]
+			rows = [row for i, row in enumerate(data) if i not in rmidxs]
+			section_total += subtotal(rows, value_field)
+			if show_detail: new_data += rows
+		new_data += [ {columns[1]['fieldname']: section_name, columns[2]['fieldname']: section_total} ]
+		summary += [ {'label': section_name, 'datatype': 'Currency', 'value': section_total} ]
+		if show_detail: new_data += [ {} ]
+	return new_data if new_data else data, summary
+
+def filter_match(value, string):
+	"Approximation to datatable filters"
+	import datetime
+	if string == '': return True
+	if value is None: value = -999999999999999
+	elif isinstance(value, datetime.date): return True
+
+	if isinstance(value, str):
+		value = value.lower()
+		string = string.lower()
+		if string[0] == '<': return True if string[1:].strip() else False
+		elif string[0] == '>': return False if string[1:].strip() else True
+		elif string[0] == '=': return string[1:] in value if string[1:] else False
+		elif string[0:2] == '!=': return string[2:] not in value
+		elif len(string.split(':')) == 2:
+			pre, post = string.split(':')
+			return (True if not pre.strip() and post.strip() in value else False)
+		else:
+			return string in value
+	else:
+		if string[0] in ['<', '>', '=']:
+			operator = string[0]
+			if operator == '=': operator = '=='
+			string = string[1:].strip()
+		elif string[0:2] == '!=':
+			operator = '!='
+			string = string[2:].strip()
+		elif len(string.split(':')) == 2:
+			pre, post = string.split(':')
+			try:
+				return (True if float(pre) <= value and float(post) >= value else False)
+			except ValueError:
+				return (False if pre.strip() else True)
+		else:
+			return string in str(value)
+
+	try:
+		num = float(string) if string.strip() else 0
+		return eval(f'{value} {operator} {num}')
+	except ValueError:
+		if operator == '<': return True
+		return False
+
+def subtotal(data, field):
+	subtotal = 0.0
+	for row in data:
+		subtotal += row[field]
+	return subtotal
 
 abbrev = lambda dt: ''.join(l[0].lower() for l in dt.split(' ')) + '.'
 doclist = lambda dt, dfs: [abbrev(dt) + f for f in dfs]
@@ -148,24 +227,18 @@
 	return reports_dict
 
 @frappe.whitelist()
-def save_custom_report(reference_report, report_name, columns, sections):
-	import pymysql
+def save_custom_report(reference_report, report_name, data):
 	if reference_report != 'Tax Detail':
 		frappe.throw(_("The wrong report is referenced."))
 	if report_name == 'Tax Detail':
 		frappe.throw(_("The parent report cannot be overwritten."))
 
-	data = {
-		'columns': json.loads(columns),
-		'sections': json.loads(sections)
-	}
-
 	doc = {
 		'doctype': 'Report',
 		'report_name': report_name,
 		'is_standard': 'No',
 		'module': 'Accounts',
-		'json': json.dumps(data, separators=(',', ':'))
+		'json': data
 	}
 	doc.update(custom_report_dict)
 
@@ -173,7 +246,7 @@
 		newdoc = frappe.get_doc(doc)
 		newdoc.insert()
 		frappe.msgprint(_("Report created successfully"))
-	except (frappe.exceptions.DuplicateEntryError, pymysql.err.IntegrityError):
+	except frappe.exceptions.DuplicateEntryError:
 		dbdoc = frappe.get_doc('Report', report_name)
 		dbdoc.update(doc)
 		dbdoc.save()
diff --git a/erpnext/accounts/report/tax_detail/test_tax_detail.py b/erpnext/accounts/report/tax_detail/test_tax_detail.py
new file mode 100644
index 0000000..dfd8d9e
--- /dev/null
+++ b/erpnext/accounts/report/tax_detail/test_tax_detail.py
@@ -0,0 +1,67 @@
+from __future__ import unicode_literals
+
+import frappe, unittest, datetime
+from frappe.utils import getdate
+from .tax_detail import execute, filter_match
+
+class TestTaxDetail(unittest.TestCase):
+	def setup(self):
+		pass
+
+	def test_filter_match(self):
+		# None - treated as -inf number except range
+		self.assertTrue(filter_match(None, '!='))
+		self.assertTrue(filter_match(None, '<'))
+		self.assertTrue(filter_match(None, '<jjj'))
+		self.assertTrue(filter_match(None, '  :  '))
+		self.assertTrue(filter_match(None, ':56'))
+		self.assertTrue(filter_match(None, ':de'))
+		self.assertFalse(filter_match(None, '3.4'))
+		self.assertFalse(filter_match(None, '='))
+		self.assertFalse(filter_match(None, '=3.4'))
+		self.assertFalse(filter_match(None, '>3.4'))
+		self.assertFalse(filter_match(None, '   <'))
+		self.assertFalse(filter_match(None, 'ew'))
+		self.assertFalse(filter_match(None, ' '))
+		self.assertFalse(filter_match(None, ' f :'))
+
+		# Numbers
+		self.assertTrue(filter_match(3.4, '3.4'))
+		self.assertTrue(filter_match(3.4, '.4'))
+		self.assertTrue(filter_match(3.4, '3'))
+		self.assertTrue(filter_match(-3.4, '< -3'))
+		self.assertTrue(filter_match(-3.4, '> -4'))
+		self.assertTrue(filter_match(3.4, '= 3.4 '))
+		self.assertTrue(filter_match(3.4, '!=4.5'))
+		self.assertTrue(filter_match(3.4, ' 3 : 4 '))
+		self.assertTrue(filter_match(0.0, '  :  '))
+		self.assertFalse(filter_match(3.4, '=4.5'))
+		self.assertFalse(filter_match(3.4, ' = 3.4 '))
+		self.assertFalse(filter_match(3.4, '!=3.4'))
+		self.assertFalse(filter_match(3.4, '>6'))
+		self.assertFalse(filter_match(3.4, '<-4.5'))
+		self.assertFalse(filter_match(3.4, '4.5'))
+		self.assertFalse(filter_match(3.4, '5:9'))
+
+		# Strings
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', 'SINV'))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', 'sinv'))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', '-2021'))
+		self.assertTrue(filter_match(' ACC-SINV-2021-00001', ' acc'))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', '=2021'))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', '!=zz'))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', '<   zzz  '))
+		self.assertTrue(filter_match('ACC-SINV-2021-00001', '  :  sinv  '))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '  sinv  :'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', ' acc'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '= 2021 '))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '!=sinv'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', ' >'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '>aa'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', ' <'))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '<   '))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', ' ='))
+		self.assertFalse(filter_match('ACC-SINV-2021-00001', '='))
+
+		# Date - always match
+		self.assertTrue(filter_match(datetime.date(2021, 3, 19), ' kdsjkldfs '))