From 675b23c47cd6eb2d784815a491193cfcfbeff288 Mon Sep 17 00:00:00 2001 From: Shubh Doshi Date: Wed, 18 Feb 2026 13:41:28 +0530 Subject: [PATCH] fix: validate doc_status for non-submittable doctypes in workflow --- .../components/Properties.vue | 13 ++++ frappe/public/js/workflow_builder/store.js | 8 ++- .../doctype/workflow/test_workflow.py | 62 +++++++++++++++++-- frappe/workflow/doctype/workflow/workflow.js | 24 ++++++- frappe/workflow/doctype/workflow/workflow.py | 23 ++++++- 5 files changed, 118 insertions(+), 12 deletions(-) diff --git a/frappe/public/js/workflow_builder/components/Properties.vue b/frappe/public/js/workflow_builder/components/Properties.vue index 12b3f4371e..51ca48f317 100644 --- a/frappe/public/js/workflow_builder/components/Properties.vue +++ b/frappe/public/js/workflow_builder/components/Properties.vue @@ -4,6 +4,11 @@ import { useStore } from "../store"; let store = useStore(); +const is_doc_status_readonly = computed(() => { + if (!store.workflow.selected || !("state" in store.workflow.selected.data)) return false; + return !store.is_submittable(); +}); + let title = ref("Workflow Details"); let doc = computed(() => { @@ -30,6 +35,13 @@ let properties = computed(() => { ); store.statefields.splice(2, 0, allow_edit); + const submittable = store.is_submittable(); + + // Auto-reset doc_status to "Draft" for non-submittable doctypes + if (!submittable && store.workflow.selected.data.doc_status !== "Draft") { + store.workflow.selected.data.doc_status = "Draft"; + } + return store.statefields.filter((df) => { if (df.fieldname == "doc_status") { df.options = ["Draft", "Submitted", "Cancelled"]; @@ -61,6 +73,7 @@ let properties = computed(() => { v-model="doc[df.fieldname]" :data-fieldname="df.fieldname" :data-fieldtype="df.fieldtype" + :read_only="df.fieldname === 'doc_status' ? is_doc_status_readonly : false" /> diff --git a/frappe/public/js/workflow_builder/store.js b/frappe/public/js/workflow_builder/store.js index bbb4925ff5..4c08a58b91 100644 --- a/frappe/public/js/workflow_builder/store.js +++ b/frappe/public/js/workflow_builder/store.js @@ -135,13 +135,18 @@ export const useStore = defineStore("workflow-builder-store", () => { frappe.breadcrumbs.$breadcrumbs.append(breadcrumbs); } + function is_submittable() { + if (!workflow_doc.value?.document_type) return true; + return frappe.get_meta(workflow_doc.value.document_type)?.is_submittable; + } + function get_state_df(data) { let doc_status_map = { Draft: 0, Submitted: 1, Cancelled: 2, }; - data.doc_status = doc_status_map[data.doc_status]; + data.doc_status = is_submittable() ? doc_status_map[data.doc_status] : 0; return data; } @@ -234,5 +239,6 @@ export const useStore = defineStore("workflow-builder-store", () => { reset_changes, save_changes, setup_undo_redo, + is_submittable, }; }); diff --git a/frappe/workflow/doctype/workflow/test_workflow.py b/frappe/workflow/doctype/workflow/test_workflow.py index 7146f299fd..5bab3d043d 100644 --- a/frappe/workflow/doctype/workflow/test_workflow.py +++ b/frappe/workflow/doctype/workflow/test_workflow.py @@ -108,14 +108,15 @@ class TestWorkflow(IntegrationTestCase): self.assertEqual(workflow_actions[0].status, "Completed") def test_if_workflow_set_on_action(self): + self.workflow, doc = create_new_submittable_doctype_with_workflow() self.workflow._update_state_docstatus = True self.workflow.states[1].doc_status = 1 self.workflow.save() - todo = create_new_todo() - self.assertEqual(todo.docstatus, 0) - todo.submit() - self.assertEqual(todo.docstatus, 1) - self.assertEqual(todo.workflow_state, "Approved") + + self.assertEqual(doc.docstatus, 0) + doc.submit() + self.assertEqual(doc.docstatus, 1) + self.assertEqual(doc.workflow_state, "Approved") self.workflow.states[1].doc_status = 0 self.workflow.save() @@ -350,6 +351,57 @@ def create_new_todo(): return frappe.get_doc(doctype="ToDo", description="workflow " + random_string(10)).insert() +def create_new_submittable_doctype_with_workflow(): + submittable_dt = frappe.get_doc( + { + "doctype": "DocType", + "module": "Core", + "name": "Test Submittable Doc", + "custom": 1, + "is_submittable": 1, + "fields": [ + {"label": "Field", "fieldname": "test_field", "fieldtype": "Data"}, + { + "label": "Workflow State", + "fieldname": "workflow_state", + "fieldtype": "Link", + "options": "Workflow State", + }, + ], + "permissions": [{"role": "System Manager", "read": 1, "write": 1, "submit": 1, "cancel": 1}], + } + ).insert(ignore_if_duplicate=True) + + workflow = None + if not frappe.db.exists("Workflow", "Submittable Workflow"): + workflow = frappe.new_doc("Workflow") + workflow.workflow_name = "Submittable Workflow" + workflow.document_type = submittable_dt.name + workflow.workflow_state_field = "workflow_state" + workflow.is_active = 1 + workflow.append("states", dict(state="Pending", allow_edit="All")) + workflow.append( + "states", + dict(state="Approved", allow_edit="System Manager", doc_status=0), + ) + workflow.append( + "transitions", + dict( + state="Pending", + action="Approve", + next_state="Approved", + allowed="System Manager", + allow_self_approval=1, + ), + ) + workflow.insert(ignore_permissions=True) + else: + workflow = frappe.get_doc("Workflow", "Submittable Workflow") + + doc = frappe.get_doc({"doctype": submittable_dt.name, "test_field": "test"}).insert() + return workflow, doc + + def create_new_note(doc): note = frappe.new_doc("Note") note.title = "workflow - " + doc.name diff --git a/frappe/workflow/doctype/workflow/workflow.js b/frappe/workflow/doctype/workflow/workflow.js index 3346f6f519..243483f085 100644 --- a/frappe/workflow/doctype/workflow/workflow.js +++ b/frappe/workflow/doctype/workflow/workflow.js @@ -109,9 +109,10 @@ frappe.ui.form.on("Workflow", { return; } frappe.model.with_doctype(doc.document_type, () => { - const fieldnames = frappe - .get_meta(doc.document_type) - .fields.filter((field) => !frappe.model.no_value_type.includes(field.fieldtype)) + const meta = frappe.get_meta(doc.document_type); + const is_submittable = meta.is_submittable; + const fieldnames = meta.fields + .filter((field) => !frappe.model.no_value_type.includes(field.fieldtype)) .map((field) => field.fieldname); frm.fields_dict.states.grid.update_docfield_property( @@ -119,6 +120,23 @@ frappe.ui.form.on("Workflow", { "options", [""].concat(fieldnames) ); + + frm.fields_dict.states.grid.update_docfield_property( + "doc_status", + "read_only", + !is_submittable + ); + + if (!is_submittable) { + let changed = false; + frm.doc.states.forEach((row) => { + if (parseInt(row.doc_status || 0) !== 0) { + row.doc_status = "0"; + changed = true; + } + }); + if (changed) frm.refresh_field("states"); + } }); }, create_warning_dialog: function (frm) { diff --git a/frappe/workflow/doctype/workflow/workflow.py b/frappe/workflow/doctype/workflow/workflow.py index 710c153f0c..c5c6320f00 100644 --- a/frappe/workflow/doctype/workflow/workflow.py +++ b/frappe/workflow/doctype/workflow/workflow.py @@ -90,23 +90,40 @@ class Workflow(Document): frappe.throw(frappe._("{0} not a valid State").format(state)) + # Check if doctype is submittable + meta = frappe.get_meta(self.document_type) + is_submittable = meta.is_submittable + + # Validate that non-submittable doctypes only have doc_status 0 + if not is_submittable: + for state in self.states: + if cint(state.doc_status or 0) != 0: + frappe.throw( + frappe._( + "Workflow State '{0}' has Document Status {1}, but DocType '{2}' is not submittable. " + "Only Document Status 0 (Draft) is allowed for non-submittable DocTypes." + ).format(state.state, state.doc_status, self.document_type) + ) + for t in self.transitions: state = get_state(t.state) next_state = get_state(t.next_state) + state_docstatus = cint(state.doc_status or 0) + next_state_docstatus = cint(next_state.doc_status or 0) - if state.doc_status == "2": + if state_docstatus == 2: frappe.throw( frappe._("Cannot change state of Cancelled Document. Transition row {0}").format(t.idx) ) - if state.doc_status == "1" and next_state.doc_status == "0": + if state_docstatus == 1 and next_state_docstatus == 0: frappe.throw( frappe._( "Submitted Document cannot be converted back to draft. Transition row {0}" ).format(t.idx) ) - if state.doc_status == "0" and next_state.doc_status == "2": + if state_docstatus == 0 and next_state_docstatus == 2: frappe.throw(frappe._("Cannot cancel before submitting. See Transition {0}").format(t.idx)) def set_active(self):