diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index b55cec332bd..ec780e63e68 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -121,7 +121,7 @@ "in_list_view": 1, "label": "% Completed", "no_copy": 1, - "read_only": 1 + "read_only_depends_on": "eval:doc.percent_complete_method != 'Manual'" }, { "fieldname": "column_break_5", @@ -484,7 +484,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2026-07-14 14:32:11.328347", + "modified": "2026-07-21 11:23:22.000000", "modified_by": "Administrator", "module": "Projects", "name": "Project", diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 814c59525a9..981acff98c3 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -222,6 +222,8 @@ class Project(Document): if self.percent_complete_method == "Manual": if self.status == "Completed": self.percent_complete = 100 + elif flt(self.percent_complete) < 0 or flt(self.percent_complete) > 100: + frappe.throw(_("% Complete must be between 0 and 100")) return total = frappe.db.count("Task", dict(project=self.name)) diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index dd10fb48ba7..56d74cb4b2e 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -244,6 +244,61 @@ class TestProject(ERPNextTestSuite): project.save() self.assertEqual(project.status, "Completed") + def _project_with_tasks(self, method, count): + name = f"_Test PercentComplete {frappe.generate_hash(length=8)}" + project = frappe.get_doc( + { + "doctype": "Project", + "project_name": name, + "status": "Open", + "percent_complete_method": method, + "company": "_Test Company", + "expected_start_date": nowdate(), + } + ).insert() + task_names = [] + for i in range(count): + task = frappe.get_doc( + { + "doctype": "Task", + "subject": f"{name} Task {i}", + "project": project.name, + "status": "Open", + "exp_start_date": nowdate(), + "exp_end_date": nowdate(), + } + ).insert() + task_names.append(task.name) + return project, task_names + + def test_percent_complete_manual(self): + project, tasks = self._project_with_tasks("Manual", 2) + + # manual value is preserved on save, even with linked tasks + project.percent_complete = 42 + project.save() + self.assertEqual(project.percent_complete, 42) + + # task updates do not overwrite the manual value + frappe.db.set_value("Task", tasks[0], "status", "Completed") + project.update_percent_complete() + self.assertEqual(project.percent_complete, 42) + + # out-of-range values are rejected + project.percent_complete = 150 + self.assertRaises(frappe.ValidationError, project.save) + project.reload() + + project.percent_complete = -10 + self.assertRaises(frappe.ValidationError, project.save) + project.reload() + + # Completed status forces 100 regardless of the manual value + project.percent_complete = 42 + project.status = "Completed" + project.save() + self.assertEqual(project.percent_complete, 100) + def _create_portal_user(self, email): """A user with no Project-related role, so read access can only come from control_access_for_project_users() sharing the doc with them."""