Merge pull request #34021 from ruthra-kumar/handle_rare_cases_of_null_in_outstanding_calculation

fix: rare instances of IntegrityError while cancelling journals against cr note
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 154fdc0..675a328 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -234,7 +234,7 @@
 	def allocate_entries(self, args):
 		self.validate_entries()
 
-		invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"))
+		invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
 		default_exchange_gain_loss_account = frappe.get_cached_value(
 			"Company", self.company, "exchange_gain_loss_account"
 		)
@@ -253,6 +253,9 @@
 					pay["amount"] = 0
 
 				inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))
+				if pay.get("reference_type") in ["Sales Invoice", "Purchase Invoice"]:
+					pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
+
 				res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
 				res.difference_account = default_exchange_gain_loss_account
 				res.exchange_rate = inv.get("exchange_rate")
@@ -407,13 +410,21 @@
 		if not self.get("payments"):
 			frappe.throw(_("No records found in the Payments table"))
 
-	def get_invoice_exchange_map(self, invoices):
+	def get_invoice_exchange_map(self, invoices, payments):
 		sales_invoices = [
 			d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Sales Invoice"
 		]
+
+		sales_invoices.extend(
+			[d.get("reference_name") for d in payments if d.get("reference_type") == "Sales Invoice"]
+		)
 		purchase_invoices = [
 			d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Purchase Invoice"
 		]
+		purchase_invoices.extend(
+			[d.get("reference_name") for d in payments if d.get("reference_type") == "Purchase Invoice"]
+		)
+
 		invoice_exchange_map = frappe._dict()
 
 		if sales_invoices:
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 00e3934..f9dda05 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -473,6 +473,11 @@
 		invoices = [x.as_dict() for x in pr.get("invoices")]
 		payments = [x.as_dict() for x in pr.get("payments")]
 		pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+		# Cr Note and Invoice are of the same currency. There shouldn't any difference amount.
+		for row in pr.allocation:
+			self.assertEqual(flt(row.get("difference_amount")), 0.0)
+
 		pr.reconcile()
 
 		pr.get_unreconciled_entries()
@@ -506,6 +511,11 @@
 		payments = [x.as_dict() for x in pr.get("payments")]
 		pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
 		pr.allocation[0].allocated_amount = allocated_amount
+
+		# Cr Note and Invoice are of the same currency. There shouldn't any difference amount.
+		for row in pr.allocation:
+			self.assertEqual(flt(row.get("difference_amount")), 0.0)
+
 		pr.reconcile()
 
 		# assert outstanding
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 6b42e4d..b348bd3 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -85,11 +85,15 @@
 		}
 
 		if (doc.docstatus == 1 && !["Lost", "Ordered"].includes(doc.status)) {
-			this.frm.add_custom_button(
-				__("Sales Order"),
-				this.frm.cscript["Make Sales Order"],
-				__("Create")
-			);
+			if (frappe.boot.sysdefaults.allow_sales_order_creation_for_expired_quotation
+				|| (!doc.valid_till)
+				|| frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) {
+					this.frm.add_custom_button(
+						__("Sales Order"),
+						this.frm.cscript["Make Sales Order"],
+						__("Create")
+					);
+				}
 
 			if(doc.status!=="Ordered") {
 				this.frm.add_custom_button(__('Set as Lost'), () => {
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 6836d56..063813b 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -195,6 +195,17 @@
 
 @frappe.whitelist()
 def make_sales_order(source_name: str, target_doc=None):
+	if not frappe.db.get_singles_value(
+		"Selling Settings", "allow_sales_order_creation_for_expired_quotation"
+	):
+		quotation = frappe.db.get_value(
+			"Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1
+		)
+		if quotation.valid_till and (
+			quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate())
+		):
+			frappe.throw(_("Validity period of this quotation has ended."))
+
 	return _make_sales_order(source_name, target_doc)
 
 
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index 5aaba4f..cdf5f5d 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -144,11 +144,21 @@
 	def test_so_from_expired_quotation(self):
 		from erpnext.selling.doctype.quotation.quotation import make_sales_order
 
+		frappe.db.set_single_value(
+			"Selling Settings", "allow_sales_order_creation_for_expired_quotation", 0
+		)
+
 		quotation = frappe.copy_doc(test_records[0])
 		quotation.valid_till = add_days(nowdate(), -1)
 		quotation.insert()
 		quotation.submit()
 
+		self.assertRaises(frappe.ValidationError, make_sales_order, quotation.name)
+
+		frappe.db.set_single_value(
+			"Selling Settings", "allow_sales_order_creation_for_expired_quotation", 1
+		)
+
 		make_sales_order(quotation.name)
 
 	def test_shopping_cart_without_website_item(self):
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json
index 2abb169..6ea66a0 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.json
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.json
@@ -27,6 +27,7 @@
   "column_break_5",
   "allow_multiple_items",
   "allow_against_multiple_purchase_orders",
+  "allow_sales_order_creation_for_expired_quotation",
   "hide_tax_id",
   "enable_discount_accounting"
  ],
@@ -172,6 +173,12 @@
    "fieldname": "enable_discount_accounting",
    "fieldtype": "Check",
    "label": "Enable Discount Accounting for Selling"
+  },
+  {
+   "default": "0",
+   "fieldname": "allow_sales_order_creation_for_expired_quotation",
+   "fieldtype": "Check",
+   "label": "Allow Sales Order Creation For Expired Quotation"
   }
  ],
  "icon": "fa fa-cog",
@@ -179,7 +186,7 @@
  "index_web_pages_for_search": 1,
  "issingle": 1,
  "links": [],
- "modified": "2022-05-31 19:39:48.398738",
+ "modified": "2023-02-04 12:37:53.380857",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Selling Settings",
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
index 991ac71..990d736 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
@@ -103,6 +103,11 @@
 				return options
 			}
 		},
+		{
+			"fieldname":"only_immediate_upcoming_term",
+			"label": __("Show only the Immediate Upcoming Term"),
+			"fieldtype": "Check",
+		},
 	]
 	return filters;
 }
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
index 8bf5686..3682c5f 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
@@ -4,6 +4,7 @@
 import frappe
 from frappe import _, qb, query_builder
 from frappe.query_builder import Criterion, functions
+from frappe.utils.dateutils import getdate
 
 
 def get_columns():
@@ -208,6 +209,7 @@
 		)
 		.where(
 			(so.docstatus == 1)
+			& (so.status.isin(["To Deliver and Bill", "To Bill"]))
 			& (so.payment_terms_template != "NULL")
 			& (so.company == conditions.company)
 			& (so.transaction_date[conditions.start_date : conditions.end_date])
@@ -291,6 +293,18 @@
 	return sales_orders
 
 
+def filter_for_immediate_upcoming_term(filters, sales_orders):
+	if filters.only_immediate_upcoming_term and sales_orders:
+		immediate_term_found = set()
+		filtered_data = []
+		for order in sales_orders:
+			if order.name not in immediate_term_found and order.due_date > getdate():
+				filtered_data.append(order)
+				immediate_term_found.add(order.name)
+		return filtered_data
+	return sales_orders
+
+
 def execute(filters=None):
 	columns = get_columns()
 	sales_orders, so_invoices = get_so_with_invoices(filters)
@@ -298,6 +312,8 @@
 
 	sales_orders = filter_on_calculated_status(filters, sales_orders)
 
+	sales_orders = filter_for_immediate_upcoming_term(filters, sales_orders)
+
 	prepare_chart(sales_orders)
 
 	data = sales_orders
diff --git a/erpnext/startup/boot.py b/erpnext/startup/boot.py
index bb120ea..62936fc 100644
--- a/erpnext/startup/boot.py
+++ b/erpnext/startup/boot.py
@@ -25,6 +25,12 @@
 			frappe.db.get_single_value("CRM Settings", "default_valid_till")
 		)
 
+		bootinfo.sysdefaults.allow_sales_order_creation_for_expired_quotation = cint(
+			frappe.db.get_single_value(
+				"Selling Settings", "allow_sales_order_creation_for_expired_quotation"
+			)
+		)
+
 		# if no company, show a dialog box to create a new company
 		bootinfo.customer_count = frappe.db.sql("""SELECT count(*) FROM `tabCustomer`""")[0][0]