diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index f9834d2ccef..4953be544a5 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -26,25 +26,35 @@ class Campaign(Document): # end: auto-generated types def after_insert(self): - try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name - mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name - mc.save(ignore_permissions=True) + self.sync_utm_campaign() def on_change(self): - try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name + self.sync_utm_campaign() + + def sync_utm_campaign(self): + mc = self.get_utm_campaign_mirror() mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name + # link by the document name, which differs from campaign_name when a naming series is used + mc.crm_campaign = self.name mc.save(ignore_permissions=True) + def get_utm_campaign_mirror(self): + # the mirror already linked to this Campaign, if any (survives campaign_name edits) + if owned := frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}): + return frappe.get_doc("UTM Campaign", owned) + + # reuse a same-named mirror only when it isn't already owned by another Campaign, + # otherwise two Campaigns sharing a display name would hijack each other's mirror + if frappe.db.exists("UTM Campaign", self.campaign_name): + same_name = frappe.get_doc("UTM Campaign", self.campaign_name) + if not same_name.crm_campaign or same_name.crm_campaign == self.name: + return same_name + + # create a fresh mirror, keeping its name unique when the display name is taken + mc = frappe.new_doc("UTM Campaign") + mc.name = self.name if frappe.db.exists("UTM Campaign", self.campaign_name) else self.campaign_name + return mc + def autoname(self): if frappe.defaults.get_global_default("campaign_naming_by") != "Naming Series": self.name = self.campaign_name diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 8876e640475..e3f41e9958f 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -1,9 +1,70 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe + +import frappe from erpnext.tests.utils import ERPNextTestSuite class TestCampaign(ERPNextTestSuite): - pass + """Campaign names itself from the campaign name (or a naming series) and mirrors + itself into a UTM Campaign.""" + + def setUp(self): + frappe.set_user("Administrator") + + def make_campaign(self, **fields): + doc = frappe.new_doc("Campaign") + doc.campaign_name = fields.pop("campaign_name", f"_Test Campaign {frappe.generate_hash(length=6)}") + doc.update(fields) + return doc.insert() + + def test_autoname_uses_the_campaign_name_by_default(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Named") + self.assertEqual(campaign.name, "_Test Campaign Named") + + def test_autoname_uses_naming_series_when_configured(self): + # regression: with a naming series the document name differs from campaign_name, + # and the UTM sync must still link back to a valid Campaign (self.name) + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + campaign = self.make_campaign(naming_series="SAL-CAM-.YYYY.-") + self.assertTrue(campaign.name.startswith("SAL-CAM-")) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.crm_campaign, campaign.name) + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + + def test_inserting_mirrors_into_a_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push") + self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name)) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.campaign_description, "Spring push") + self.assertEqual(utm.crm_campaign, campaign.name) + + def test_editing_campaign_name_reuses_the_same_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Rename A") + campaign.campaign_name = "_Test Campaign Rename B" + campaign.save() + # the edit updates the existing mirror rather than creating a second one + mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name}) + self.assertEqual(len(mirrors), 1) + + def test_two_campaigns_sharing_a_name_do_not_hijack_each_others_mirror(self): + # a naming series lets two Campaigns share a display name; each must keep its own mirror + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + first = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + second = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + + # the first Campaign's mirror is untouched; the second gets a distinct one + self.assertEqual( + frappe.db.get_value("UTM Campaign", "_Test Shared Mirror", "crm_campaign"), first.name + ) + second_mirror = frappe.db.get_value("UTM Campaign", {"crm_campaign": second.name}) + self.assertTrue(second_mirror) + self.assertNotEqual(second_mirror, "_Test Shared Mirror")