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 fc85099bf6c..ab2dc14b518 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -278,6 +278,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 96a74ec5d0d..abc5fd248b4 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -349,6 +349,34 @@ class TestProject(ERPNextTestSuite): self.assertEqual(project.percent_complete, 75) self.assertEqual(project.status, "On hold") + 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 test_percent_complete_by_task_progress(self): project, tasks = self._project_with_tasks("Task Progress", 2)