From 61c2e7ad6ec2a4733abf2151e7a24de5e730f333 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 22 Jun 2026 12:33:19 +0530 Subject: [PATCH 1/2] test(opportunity): cover the mark-as-lost flow declare_enquiry_lost had almost no coverage. Add tests that marking an Opportunity as lost records the lost reasons, competitors and detailed reason and sets status to Lost, and that it is blocked when an active (submitted) Quotation exists. --- .../doctype/opportunity/test_opportunity.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 62fad25a574..815ab992412 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -100,6 +100,42 @@ class TestOpportunity(ERPNextTestSuite): opp.opportunity_owner = None self.assertIsNone(opp.get_notification_email()) + def test_declare_enquiry_lost(self): + lost_reason = _ensure_master("Opportunity Lost Reason", "lost_reason", "_Test Lost - Too Expensive") + competitor = _ensure_master("Competitor", "competitor_name", "_Test Competitor") + + opp = make_opportunity(with_items=0) + opp.declare_enquiry_lost( + lost_reasons_list=[{"lost_reason": lost_reason}], + competitors=[{"competitor": competitor}], + detailed_reason="Budget too high", + ) + + opp.reload() + self.assertEqual(opp.status, "Lost") + self.assertEqual(opp.order_lost_reason, "Budget too high") + self.assertEqual([d.lost_reason for d in opp.lost_reasons], [lost_reason]) + self.assertEqual([d.competitor for d in opp.competitors], [competitor]) + + def test_declare_lost_blocked_when_quotation_active(self): + opp = make_opportunity(with_items=0) + quotation = make_quotation(opp.name) + quotation.append("items", {"item_code": "_Test Item", "qty": 1}) + quotation.run_method("set_missing_values") + quotation.run_method("calculate_taxes_and_totals") + quotation.submit() + + # A submitted, still-active quotation exists, so the opportunity can't be marked lost + opp.reload() + self.assertRaises(frappe.ValidationError, opp.declare_enquiry_lost, [], [], "x") + self.assertNotEqual(opp.status, "Lost") + + +def _ensure_master(doctype, fieldname, value): + if not frappe.db.exists(doctype, value): + frappe.get_doc({"doctype": doctype, fieldname: value}).insert(ignore_permissions=True) + return value + def make_opportunity_from_lead(company): new_lead_email_id = f"new{random_string(5)}@example.com" From 017e09eaac54025327ba49fbbd6f298030b5c1f2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 22 Jun 2026 12:45:12 +0530 Subject: [PATCH 2/2] test(opportunity): cover item details, auto-close and prospect sync Add tests for get_item_details, auto_close_opportunity (a stale Replied opportunity is closed, a recent one is not) and the Opportunity -> Prospect opportunity sync. Opportunity controller coverage 62% -> 80%. --- .../doctype/opportunity/test_opportunity.py | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 815ab992412..e1c3cdaa2bf 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -2,11 +2,12 @@ # See license.txt import frappe -from frappe.utils import now_datetime, random_string, today +from frappe.utils import add_days, now_datetime, random_string, today from erpnext.crm.doctype.lead.mapper import make_customer from erpnext.crm.doctype.lead.test_lead import make_lead from erpnext.crm.doctype.opportunity.mapper import make_quotation +from erpnext.crm.doctype.opportunity.opportunity import auto_close_opportunity, get_item_details from erpnext.crm.utils import get_linked_communication_list from erpnext.tests.utils import ERPNextTestSuite @@ -130,6 +131,59 @@ class TestOpportunity(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, opp.declare_enquiry_lost, [], [], "x") self.assertNotEqual(opp.status, "Lost") + def test_get_item_details(self): + details = get_item_details("_Test Item") + self.assertEqual(details["item_name"], frappe.db.get_value("Item", "_Test Item", "item_name")) + self.assertEqual(details["uom"], frappe.db.get_value("Item", "_Test Item", "stock_uom")) + + # an unknown item returns blank fields rather than erroring + self.assertEqual(get_item_details("_Non Existent Item XYZ")["item_name"], "") + + def test_auto_close_replied_opportunity(self): + days = frappe.db.get_single_value("CRM Settings", "close_opportunity_after_days") or 15 + + stale = make_opportunity(with_items=0) + fresh = make_opportunity(with_items=0) + for opp in (stale, fresh): + frappe.db.set_value("Opportunity", opp.name, "status", "Replied", update_modified=False) + # age only the stale opportunity past the threshold + frappe.db.set_value( + "Opportunity", + stale.name, + "modified", + add_days(now_datetime(), -(days + 1)), + update_modified=False, + ) + + auto_close_opportunity() + + self.assertEqual(frappe.db.get_value("Opportunity", stale.name, "status"), "Closed") + self.assertEqual(frappe.db.get_value("Opportunity", fresh.name, "status"), "Replied") + + def test_opportunity_synced_to_prospect(self): + prospect_name = "_Test Prospect For Opportunity" + if not frappe.db.exists("Prospect", prospect_name): + frappe.get_doc( + {"doctype": "Prospect", "company_name": prospect_name, "company": "_Test Company"} + ).insert(ignore_permissions=True) + + opp = frappe.get_doc( + { + "doctype": "Opportunity", + "company": "_Test Company", + "opportunity_from": "Prospect", + "party_name": prospect_name, + "opportunity_type": "Sales", + "sales_stage": "Prospecting", + "transaction_date": today(), + } + ).insert(ignore_permissions=True) + + prospect = frappe.get_doc("Prospect", prospect_name) + linked = {d.opportunity: d for d in prospect.opportunities} + self.assertIn(opp.name, linked) + self.assertEqual(linked[opp.name].stage, "Prospecting") + def _ensure_master(doctype, fieldname, value): if not frappe.db.exists(doctype, value):