From 9fa38f60ea1da77f7a2e3097b3a868db9a824163 Mon Sep 17 00:00:00 2001 From: rakesh Date: Fri, 11 Jul 2025 16:05:44 +0530 Subject: [PATCH] [feature]: (#473) Task dependency and Rework Changes --- .../service_template/service_template.py | 16 +- .../service_template_task.json | 11 +- .../service_template_task.py | 20 +- erpnext/projects/doctype/task/task.js | 8 + erpnext/projects/doctype/task/task.json | 43 ++++- erpnext/projects/doctype/task/task.py | 176 +++++++++++++++++- .../task_depends_on/task_depends_on.json | 14 +- erpnext/projects/utils.py | 52 ++++++ 8 files changed, 324 insertions(+), 16 deletions(-) diff --git a/erpnext/projects/doctype/service_template/service_template.py b/erpnext/projects/doctype/service_template/service_template.py index 2fd17fa13641..e1e4fef0a47c 100644 --- a/erpnext/projects/doctype/service_template/service_template.py +++ b/erpnext/projects/doctype/service_template/service_template.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.model.document import Document from frappe.utils import cint, clean_whitespace, cstr +from erpnext.projects.utils import validate_comma_separated_indices, check_for_circular_dependencies import json @@ -15,6 +16,7 @@ def validate(self): self.validate_duplicate_applicable_item_groups() self.validate_due_after() self.validate_service_warranty() + self.validate_task_dependencies() def onload(self): pass @@ -86,6 +88,16 @@ def filter_applicable_item(self, pt_item, applies_to_item=None, applies_to_custo return False + def validate_task_dependencies(self): + max_idx = max((row.idx for row in self.tasks if row.idx), default=0) + dependency_map = {} + + for row in self.tasks: + dep_indices = validate_comma_separated_indices(row.depends_on_task, row.idx, max_allowed_idx=max_idx) + dependency_map[row.idx] = dep_indices + + check_for_circular_dependencies(dependency_map) + @frappe.whitelist() def get_service_template_details(service_template): @@ -219,7 +231,7 @@ def get_service_template_tasks( 'service_template_name': service_template_doc.service_template_name, }) - for template_task_row in service_template_doc.tasks: + for idx, template_task_row in enumerate(service_template_doc.tasks): task_details = frappe._dict() task_details.subject = template_task_row.subject task_details.description = template_task_row.description @@ -228,6 +240,8 @@ def get_service_template_tasks( task_details.service_template = service_template_detail.service_template task_details.service_template_detail = service_template_detail.name task_details.determine_time = template_task_row.determine_time + task_details.depends_on_task = template_task_row.depends_on_task or "" + task_details.idx = idx + 1 if template_task_row.use_template_name: task_details.subject = service_template_detail.service_template_name diff --git a/erpnext/projects/doctype/service_template_task/service_template_task.json b/erpnext/projects/doctype/service_template_task/service_template_task.json index 7d3963dd19a7..803318a88cad 100644 --- a/erpnext/projects/doctype/service_template_task/service_template_task.json +++ b/erpnext/projects/doctype/service_template_task/service_template_task.json @@ -7,6 +7,7 @@ "field_order": [ "subject", "task_type", + "depends_on_task", "column_break_l9vb", "expected_time", "determine_time", @@ -17,7 +18,7 @@ ], "fields": [ { - "columns": 6, + "columns": 4, "fieldname": "subject", "fieldtype": "Data", "in_list_view": 1, @@ -69,11 +70,17 @@ "fieldname": "use_template_description", "fieldtype": "Check", "label": "Use Template Description" + }, + { + "fieldname": "depends_on_task", + "fieldtype": "Data", + "label": "Depends On Task", + "in_list_view": 1 } ], "istable": 1, "links": [], - "modified": "2025-02-01 13:22:34.628230", + "modified": "2025-07-07 14:00:59.617902", "modified_by": "Administrator", "module": "Projects", "name": "Service Template Task", diff --git a/erpnext/projects/doctype/service_template_task/service_template_task.py b/erpnext/projects/doctype/service_template_task/service_template_task.py index 0e6490906594..eaa119934d9f 100644 --- a/erpnext/projects/doctype/service_template_task/service_template_task.py +++ b/erpnext/projects/doctype/service_template_task/service_template_task.py @@ -2,8 +2,24 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document +from frappe import _ class ServiceTemplateTask(Document): - pass + def validate_depends_on_task(self): + if not self.depends_on_task: + return + + parts = [x.strip() for x in self.depends_on_task.split(',') if x.strip()] + for part in parts: + if not part.isdigit(): + frappe.throw( + _("Row #{0}: 'Depends On Task' must only contain comma-separated numbers. Found invalid value: {1}").format( + self.idx, part)) + + invalid_indices = [int(x) for x in parts if int(x) >= self.idx] + if invalid_indices: + frappe.throw(_("Row #{0}: 'Depends On Task' cannot refer to current or future rows: {1}").format( + self.idx, ", ".join(str(x) for x in invalid_indices) + )) diff --git a/erpnext/projects/doctype/task/task.js b/erpnext/projects/doctype/task/task.js index 36244ba40e77..5b187a5a5abb 100644 --- a/erpnext/projects/doctype/task/task.js +++ b/erpnext/projects/doctype/task/task.js @@ -120,6 +120,14 @@ erpnext.projects.TaskController = class TaskController extends frappe.ui.form.Co }, __("Actions")); } + if (this.get_action_condition("rework_task")) { + this.frm.add_custom_button(__("Create Rework Task"), () => { + this.frm.check_if_unsaved(); + return erpnext.task_actions.create_rework_task(this.frm.doc.name, this.frm.doc, null, + () => this.frm.reload_doc()); + }, __("Actions")); + } + if (this.get_action_condition("cancel_task")) { this.frm.add_custom_button(__("Cancel"), () => { this.frm.check_if_unsaved(); diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index 1be0c93d013a..e7587f5d9c92 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -20,6 +20,11 @@ "status", "progress", "priority", + "rework_section", + "rework_of", + "rework_column_break", + "rework_reason", + "is_rework_task", "assignment_section", "assigned_to", "assigned_to_name", @@ -542,6 +547,42 @@ { "fieldname": "column_break_mwlw", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_rework_task", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Rework Task", + "read_only": 1 + }, + { + "depends_on": "is_rework_task", + "fieldname": "rework_of", + "fieldtype": "Link", + "label": "Rework For", + "mandatory_depends_on": "is_rework_task", + "options": "Task" + }, + { + "depends_on": "is_rework_task", + "fieldname": "rework_reason", + "fieldtype": "Small Text", + "label": "Rework Reason", + "mandatory_depends_on": "is_rework_task" + }, + { + "depends_on": "is_rework_task", + "fieldname": "rework_section", + "fieldtype": "Section Break", + "label": "Rework" + }, + { + "fieldname": "rework_column_break", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", + "print_width": "50%", + "width": "50%" } ], "icon": "fa fa-check", @@ -549,7 +590,7 @@ "is_calendar_and_gantt": 1, "is_tree": 1, "links": [], - "modified": "2025-07-02 12:29:30.295539", + "modified": "2025-07-11 11:25:29.385970", "modified_by": "Administrator", "module": "Projects", "name": "Task", diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 5f281be89885..c79441e882fc 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -78,7 +78,7 @@ def validate_after_status(self): self.validate_project_status() self.set_time_and_costing() self.validate_progress() - self.validate_status_depedency() + self.validate_status_dependency() self.set_completion_values() self.set_is_overdue() @@ -194,12 +194,18 @@ def validate_progress(self): if self.status == 'Completed': self.progress = 100 - def validate_status_depedency(self): + def validate_status_dependency(self): if self.value_changed("status") and self.status == "Completed": for d in self.depends_on: - if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"): - frappe.throw(_("Cannot complete task {0} as its dependant {1} is not completed / cancelled.") - .format(frappe.bold(self.name), frappe.get_desk_link("Task", d.task))) + dependency_task = frappe.db.get_value("Task", d.task, ["subject", "status"], as_dict=True) + if dependency_task.status not in ("Completed", "Cancelled"): + frappe.throw(_( + "Cannot complete task {0} because it depends on {1}: {2}, which is not completed or cancelled." + ).format( + frappe.bold(self.name), + frappe.get_desk_link("Task", d.task), + frappe.bold(dependency_task.subject) + )) def set_completion_values(self): if self.value_changed("status") and self.status == "Completed": @@ -523,7 +529,11 @@ def create_service_template_tasks(project): template_doc = frappe.get_cached_doc("Service Template", service_template_row.service_template) template_tasks = get_service_template_tasks(service_template_row.service_template, service_template_detail=service_template_row) - for template_task_details in template_tasks: + + task_data_list = [] + task_lookup_by_index = {} + + for idx, template_task_details in enumerate(template_tasks): task_doc = frappe.new_doc("Task") for k, v in template_task_details.items(): if task_doc.meta.has_field(k): @@ -542,6 +552,29 @@ def create_service_template_tasks(project): task_doc.save() tasks_created.append(task_doc) + task_data_list.append({ + "row_index": idx + 1, + "doc": task_doc, + "depends_on_task": template_task_details.get("depends_on_task") + }) + task_lookup_by_index[idx + 1] = task_doc + + for data in task_data_list: + task_doc = data["doc"] + dependency_indices = [ + int(x.strip()) for x in (data["depends_on_task"] or "").split(",") if x.strip().isdigit() + ] + for dependency_idx in dependency_indices: + dependency_task = task_lookup_by_index.get(dependency_idx) + if dependency_task: + task_doc.append("depends_on", { + "task": dependency_task.name, + "subject": dependency_task.subject, + "project": dependency_task.project + }) + if dependency_indices: + task_doc.save() + if tasks_created: message = _("{0} Service Template tasks created against {1}

").format( len(tasks_created), @@ -880,8 +913,29 @@ def split_task(task, expected_time=None): for f in copy_fields: new_task.set(f, ref_task.get(f)) + for dep in ref_task.depends_on: + new_task.append("depends_on", { + "task": dep.task, + "subject": dep.subject, + "project": dep.project + }) + new_task.save() + dependent_tasks = frappe.get_all("Task Depends On", + filters={"task": ref_task.name}, + fields=["parent"] + ) + + for d in dependent_tasks: + parent_task = frappe.get_doc("Task", d.parent) + parent_task.append("depends_on", { + "task": new_task.name, + "subject": new_task.subject, + "project": new_task.project + }) + parent_task.save() + message = _("{0} split from {1}").format(get_link(new_task), get_link(ref_task)) frappe.msgprint(message, indicator="green") @@ -1022,6 +1076,7 @@ def _get_task_action_conditions(task, project=None): "edit_task": has_task_write and task.status != "Completed", "split_task": has_task_create and task.status not in ("Completed", "Cancelled"), "cancel_task": has_task_write and task.status == "Open", + "rework_task": has_task_create, }) frappe.utils.call_hook_method( @@ -1082,3 +1137,112 @@ def get_timelog_totals(timelogs): def on_doctype_update(): frappe.db.add_index("Task", ["lft", "rgt"]) + +@frappe.whitelist() +def create_rework_tasks(task, rework_reason): + frappe.has_permission("Task", "create", throw=True) + + ref_task = frappe.get_doc("Task", task) + ref_task.check_permission("write") + + copy_fields = [ + "subject", "description", "task_type", "project", "priority", + "service_template", "service_template_detail", "expected_time", + "weight", "color", "exp_start_date", "exp_end_date", "is_group", + "company", "branch", "vehicle_workshop_division" + ] + + existing_reworks = frappe.get_all("Task", + filters={ + "rework_of": ref_task.name, + "is_rework_task": 1, + "status": ["not in", ["Completed", "Cancelled"]], + "name": ["!=", ref_task.name] + }, + ) + if existing_reworks: + links = [frappe.utils.get_link_to_form("Task", task["name"]) for task in existing_reworks] + frappe.throw( + "Rework task(s) already exist for this task. Please complete or cancel them before creating a new one:

" + + "
".join(links) + ) + + rework_task = frappe.new_doc("Task") + for f in copy_fields: + rework_task.set(f, ref_task.get(f)) + + rework_task.subject = f"Rework of {ref_task.subject}" + rework_task.rework_of = ref_task.name + rework_task.rework_reason = rework_reason + rework_task.is_rework_task = 1 + + for dep in ref_task.depends_on: + rework_task.append("depends_on", { + "task": dep.task, + "subject": dep.subject, + "project": dep.project + }) + + rework_task.insert() + + qc_task = None + if ref_task.task_type != "QC": + qc_task = frappe.new_doc("Task") + for f in copy_fields: + qc_task.set(f, ref_task.get(f)) + + qc_task.subject = f"QC for {rework_task.subject}" + qc_task.task_type = "QC" + qc_task.rework_of = ref_task.name + qc_task.rework_reason = rework_reason + qc_task.is_rework_task = 1 + + qc_task.append("depends_on", { + "task": rework_task.name, + "subject": rework_task.subject, + "project": rework_task.project + }) + + qc_task.save() + + dependent_tasks = frappe.get_all( + "Task Depends On", + filters={"task": ref_task.name}, + fields=["parent"] + ) + + for d in dependent_tasks: + dependent_task = frappe.get_doc("Task", d.parent) + + dependent_task.append("depends_on", { + "task": rework_task.name, + "subject": rework_task.subject, + "project": rework_task.project + }) + + if qc_task: + dependent_task.append("depends_on", { + "task": qc_task.name, + "subject": qc_task.subject, + "project": qc_task.project + }) + + dependent_task.save() + + if qc_task: + message = _("Rework Task {0} and QC Task {1} created.").format( + frappe.utils.get_link_to_form("Task", rework_task.name), + frappe.utils.get_link_to_form("Task", qc_task.name) + ) + else: + message = _("Rework Task {0} created (original task is already a QC task).").format( + frappe.utils.get_link_to_form("Task", rework_task.name) + ) + + frappe.msgprint(message, indicator="green") + + return { + "rework_task": rework_task.name, + "qc_task": qc_task.name if qc_task is not None else "", + "message": message + } diff --git a/erpnext/projects/doctype/task_depends_on/task_depends_on.json b/erpnext/projects/doctype/task_depends_on/task_depends_on.json index aa3a172c6096..417861423657 100644 --- a/erpnext/projects/doctype/task_depends_on/task_depends_on.json +++ b/erpnext/projects/doctype/task_depends_on/task_depends_on.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2015-04-29 04:52:48.868079", "doctype": "DocType", "editable_grid": 1, @@ -24,28 +25,33 @@ }, { "columns": 6, + "fetch_from": "task.subject", "fieldname": "subject", - "fieldtype": "Text", + "fieldtype": "Data", "in_list_view": 1, "label": "Subject", "read_only": 1 }, { "columns": 2, + "fetch_from": "task.project", "fieldname": "project", - "fieldtype": "Text", + "fieldtype": "Link", "in_list_view": 1, "label": "Project", + "options": "Project", "read_only": 1 } ], "istable": 1, - "modified": "2022-05-17 17:47:59.143281", + "links": [], + "modified": "2025-07-07 10:12:15.775310", "modified_by": "Administrator", "module": "Projects", "name": "Task Depends On", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/projects/utils.py b/erpnext/projects/utils.py index d14ae1cbf0f9..13ddfc7a378c 100644 --- a/erpnext/projects/utils.py +++ b/erpnext/projects/utils.py @@ -4,6 +4,7 @@ # For license information, please see license.txt import frappe +from frappe import _ @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -26,3 +27,54 @@ def query_task(doctype, txt, searchfield, start, page_len, filters): (searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"), (search_string, search_string, order_by_string, order_by_string, start, page_len)) + +def validate_comma_separated_indices(value, row_idx, max_allowed_idx=None): + if not value: + return [] + + parts = [x.strip() for x in value.split(',') if x.strip()] + invalid_format = [x for x in parts if not x.isdigit()] + if invalid_format: + frappe.throw( + _("Row #{0}: Must contain only comma-separated numbers. Invalid value(s): {1}") + .format(row_idx, ", ".join(invalid_format)) + ) + + int_indices = list(map(int, parts)) + + if row_idx in int_indices: + frappe.throw( + _("Row #{0}: Cannot depend on itself. Please remove index {0} from 'Depends On Task'.").format(row_idx) + ) + + if max_allowed_idx is not None: + invalid_indices = [i for i in int_indices if i < 1 or i > max_allowed_idx] + if invalid_indices: + frappe.throw( + _("Row #{0}: Invalid task reference(s): {1}. Allowed range: 1 to {2}.") + .format(row_idx, ", ".join(map(str, invalid_indices)), max_allowed_idx) + ) + + return int_indices + + +def check_for_circular_dependencies(dependency_map): + visited = set() + stack = set() + + def visit(node): + if node in stack: + frappe.throw( + _("Circular dependency detected involving task row #{0}").format(node) + ) + if node in visited: + return + stack.add(node) + for dep in dependency_map.get(node, []): + visit(dep) + stack.remove(node) + visited.add(node) + + for node in dependency_map: + visit(node) +