feat: Group by AR/AP report (#20573)

* feat: Group by AR/AP report

* fix: Do not consider total row in charts

* fix: Subtotal row for last party
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js
index b1f427c..df700ec 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.js
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js
@@ -101,6 +101,11 @@
 			"options": "Supplier Group"
 		},
 		{
+			"fieldname": "group_by_party",
+			"label": __("Group By Supplier"),
+			"fieldtype": "Check"
+		},
+		{
 			"fieldname":"based_on_payment_terms",
 			"label": __("Based On Payment Terms"),
 			"fieldtype": "Check",
@@ -112,6 +117,16 @@
 			"hidden": 1
 		}
 	],
+
+	"formatter": function(value, row, column, data, default_formatter) {
+		value = default_formatter(value, row, column, data);
+		if (data && data.bold) {
+			value = value.bold();
+
+		}
+		return value;
+	},
+
 	onload: function(report) {
 		report.page.add_inner_button(__("Accounts Payable Summary"), function() {
 			var filters = report.get_values();
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index 9b4dda2..5d0154f 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -87,7 +87,7 @@
 						frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]);
 					});
 
-					frappe.db.get_value('Customer Credit Limit', {'parent': customer, 'company': company}, 
+					frappe.db.get_value('Customer Credit Limit', {'parent': customer, 'company': company},
 						["credit_limit"], function(value) {
 						if (value) {
 							frappe.query_report.set_filter_value('credit_limit', value["credit_limit"]);
@@ -132,6 +132,11 @@
 			"options": "Sales Person"
 		},
 		{
+			"fieldname": "group_by_party",
+			"label": __("Group By Customer"),
+			"fieldtype": "Check"
+		},
+		{
 			"fieldname":"based_on_payment_terms",
 			"label": __("Based On Payment Terms"),
 			"fieldtype": "Check",
@@ -177,6 +182,15 @@
 		}
 	],
 
+	"formatter": function(value, row, column, data, default_formatter) {
+		value = default_formatter(value, row, column, data);
+		if (data && data.bold) {
+			value = value.bold();
+
+		}
+		return value;
+	},
+
 	onload: function(report) {
 		report.page.add_inner_button(__("Accounts Receivable Summary"), function() {
 			var filters = report.get_values();
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index f82146a..0438f6d 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -46,7 +46,7 @@
 		self.get_columns()
 		self.get_data()
 		self.get_chart_data()
-		return self.columns, self.data, None, self.chart
+		return self.columns, self.data, None, self.chart, None, self.skip_total_row
 
 	def set_defaults(self):
 		if not self.filters.get("company"):
@@ -57,6 +57,12 @@
 		self.party_type = self.filters.party_type
 		self.party_details = {}
 		self.invoices = set()
+		self.skip_total_row = 0
+
+		if self.filters.get('group_by_party'):
+			self.previous_party=''
+			self.total_row_map = {}
+			self.skip_total_row = 1
 
 	def get_data(self):
 		self.get_gl_entries()
@@ -102,6 +108,12 @@
 				)
 			self.get_invoices(gle)
 
+			if self.filters.get('group_by_party'):
+				self.init_subtotal_row(gle.party)
+
+		if self.filters.get('group_by_party'):
+			self.init_subtotal_row('Total')
+
 	def get_invoices(self, gle):
 		if gle.voucher_type in ('Sales Invoice', 'Purchase Invoice'):
 			if self.filters.get("sales_person"):
@@ -111,6 +123,20 @@
 			else:
 				self.invoices.add(gle.voucher_no)
 
+	def init_subtotal_row(self, party):
+		if not self.total_row_map.get(party):
+			self.total_row_map.setdefault(party, {
+				'party': party,
+				'bold': 1
+			})
+
+			for field in self.get_currency_fields():
+				self.total_row_map[party][field] = 0.0
+
+	def get_currency_fields(self):
+		return ['invoiced', 'paid', 'credit_note', 'outstanding', 'range1',
+			'range2', 'range3', 'range4', 'range5']
+
 	def update_voucher_balance(self, gle):
 		# get the row where this balance needs to be updated
 		# if its a payment, it will return the linked invoice or will be considered as advance
@@ -135,6 +161,18 @@
 				# advance / unlinked payment or other adjustment
 				row.paid -= gle_balance
 
+	def update_sub_total_row(self, row, party):
+		total_row = self.total_row_map.get(party)
+
+		for field in self.get_currency_fields():
+			total_row[field] += row.get(field, 0.0)
+
+	def append_subtotal_row(self, party):
+		sub_total_row = self.total_row_map.get(party)
+		self.data.append(sub_total_row)
+		self.data.append({})
+		self.update_sub_total_row(sub_total_row, 'Total')
+
 	def get_voucher_balance(self, gle):
 		if self.filters.get("sales_person"):
 			against_voucher = gle.against_voucher or gle.voucher_no
@@ -192,11 +230,22 @@
 				else:
 					self.append_row(row)
 
+		if self.filters.get('group_by_party'):
+			self.append_subtotal_row(self.previous_party)
+			self.data.append(self.total_row_map.get('Total'))
+
 	def append_row(self, row):
 		self.allocate_future_payments(row)
 		self.set_invoice_details(row)
 		self.set_party_details(row)
 		self.set_ageing(row)
+
+		if self.filters.get('group_by_party'):
+			self.update_sub_total_row(row, row.party)
+			if self.previous_party and (self.previous_party != row.party):
+				self.append_subtotal_row(self.previous_party)
+			self.previous_party = row.party
+
 		self.data.append(row)
 
 	def set_invoice_details(self, row):
@@ -503,6 +552,7 @@
 		# get all the GL entries filtered by the given filters
 
 		conditions, values = self.prepare_conditions()
+		order_by = self.get_order_by_condition()
 
 		if self.filters.get(scrub(self.party_type)):
 			select_fields = "debit_in_account_currency as debit, credit_in_account_currency as credit"
@@ -520,9 +570,8 @@
 				and party_type=%s
 				and (party is not null and party != '')
 				and posting_date <= %s
-				{1}
-			order by posting_date, party"""
-			.format(select_fields, conditions), values, as_dict=True)
+				{1} {2}"""
+			.format(select_fields, conditions, order_by), values, as_dict=True)
 
 	def get_sales_invoices_or_customers_based_on_sales_person(self):
 		if self.filters.get("sales_person"):
@@ -557,6 +606,12 @@
 
 		return " and ".join(conditions), values
 
+	def get_order_by_condition(self):
+		if self.filters.get('group_by_party'):
+			return "order by party, posting_date"
+		else:
+			return "order by posting_date, party"
+
 	def add_common_filters(self, conditions, values, party_type_field):
 		if self.filters.company:
 			conditions.append("company=%s")
@@ -736,11 +791,13 @@
 	def get_chart_data(self):
 		rows = []
 		for row in self.data:
-			values = [row.range1, row.range2, row.range3, row.range4, row.range5]
-			precision = cint(frappe.db.get_default("float_precision")) or 2
-			rows.append({
-				'values': [flt(val, precision) for val in values]
-			})
+			row = frappe._dict(row)
+			if not cint(row.bold):
+				values = [row.range1, row.range2, row.range3, row.range4, row.range5]
+				precision = cint(frappe.db.get_default("float_precision")) or 2
+				rows.append({
+					'values': [flt(val, precision) for val in values]
+				})
 
 		self.chart = {
 			"data": {