From b2eb6a69c17eb115cfe4466e32706e0663d3ad23 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 14 Jun 2026 20:17:08 +0530 Subject: [PATCH 1/3] fix(projects): don't overwrite existing Sales Order project link Creating a Project with a `sales_order` set used `frappe.db.set_value` to unconditionally write the Sales Order's `project` field. When several projects were created for the same Sales Order, each new project silently overwrote the previous link, leaving earlier projects orphaned. Only back-link the Sales Order when it is not already tied to another project, and write the value through the document's `db_set` so the modified timestamp and realtime update are handled. A warning is shown when an existing link is left untouched. Fixes #52179 --- erpnext/projects/doctype/project/project.py | 25 +++++++++++++++++-- .../projects/doctype/project/test_project.py | 18 +++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 081ac5dd96c..211fcfc8afe 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -240,8 +240,29 @@ class Project(Document): def after_insert(self): self.copy_from_template("after_insert") - if self.sales_order: - frappe.db.set_value("Sales Order", self.sales_order, "project", self.name) + self.link_with_sales_order() + + def link_with_sales_order(self) -> None: + """Back-link the source Sales Order to this project. + + The link is set only when the Sales Order is not already tied to another + project, so projects created concurrently for the same Sales Order cannot + overwrite each other's reference. + """ + if not self.sales_order: + return + + sales_order = frappe.get_doc("Sales Order", self.sales_order) + if sales_order.project and sales_order.project != self.name: + frappe.msgprint( + _("Sales Order {0} is already linked to Project {1}, skipping the link.").format( + self.sales_order, sales_order.project + ), + alert=True, + ) + return + + sales_order.db_set("project", self.name) def on_trash(self): frappe.db.set_value("Sales Order", {"project": self.name}, "project", "") diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index b24f0d13fe2..07960128855 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -174,6 +174,24 @@ class TestProject(ERPNextTestSuite): so.reload() self.assertFalse(so.project) + def test_sales_order_link_is_not_overwritten_by_second_project(self): + so = make_sales_order() + + first_project = make_project_from_so(so.name).save() + so.reload() + self.assertEqual(so.project, first_project.name) + + # A second project for the same sales order must not steal the link. + second_project = frappe.get_doc( + doctype="Project", + project_name="Second project for same sales order", + sales_order=so.name, + ).insert() + self.assertEqual(second_project.sales_order, so.name) + + so.reload() + self.assertEqual(so.project, first_project.name) + def test_project_with_template_tasks_having_common_name(self): # Step - 1: Create Template Parent Tasks template_parent_task1 = create_task(subject="Parent Task - 1", is_template=1, is_group=1) From 900c71840c1f623d2cfe173e6354b332d45a5dce Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 19 Jun 2026 12:37:43 +0530 Subject: [PATCH 2/3] test(projects): set company on second project to fix CI mandatory error The second Project in test_sales_order_link_is_not_overwritten_by_second_project was inserted without a company, which only succeeds when a default company is configured. On a fresh CI site this raised MandatoryError. Set company from the sales order explicitly. --- erpnext/projects/doctype/project/test_project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 07960128855..3ece3ea36c2 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -185,6 +185,7 @@ class TestProject(ERPNextTestSuite): second_project = frappe.get_doc( doctype="Project", project_name="Second project for same sales order", + company=so.company, sales_order=so.name, ).insert() self.assertEqual(second_project.sales_order, so.name) From db2e2105ab02cbd7bda366c55f3aa744d9e91d49 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 19 Jun 2026 14:37:32 +0530 Subject: [PATCH 3/3] fix: Use get_value instead of get_doc Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- erpnext/projects/doctype/project/project.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 211fcfc8afe..5f82b2257c4 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -252,17 +252,17 @@ class Project(Document): if not self.sales_order: return - sales_order = frappe.get_doc("Sales Order", self.sales_order) - if sales_order.project and sales_order.project != self.name: + existing_project = frappe.db.get_value("Sales Order", self.sales_order, "project") + if existing_project and existing_project != self.name: frappe.msgprint( _("Sales Order {0} is already linked to Project {1}, skipping the link.").format( - self.sales_order, sales_order.project + self.sales_order, existing_project ), alert=True, ) return - sales_order.db_set("project", self.name) + frappe.db.set_value("Sales Order", self.sales_order, "project", self.name) def on_trash(self): frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")