feat: added sequence id in routing for the completion of operations sequentially (#23641)

* feat: added sequence id in routing for the completion of operations sequentially

* fix: translation syntax
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 71d49a9..2ab1b98 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -55,10 +55,11 @@
 			conflicting_bom = frappe.get_doc("BOM", name)
 
 			if conflicting_bom.item != self.item:
+				msg = (_("A BOM with name {0} already exists for item {1}.")
+					.format(frappe.bold(name), frappe.bold(conflicting_bom.item)))
 
-				frappe.throw(_("""A BOM with name {0} already exists for item {1}.
-					<br> Did you rename the item? Please contact Administrator / Tech support
-				""").format(frappe.bold(name), frappe.bold(conflicting_bom.item)))
+				frappe.throw(_("{0}{1} Did you rename the item? Please contact Administrator / Tech support")
+					.format(msg, "<br>"))
 
 		self.name = name
 
@@ -72,6 +73,7 @@
 		self.validate_uom_is_interger()
 		self.set_bom_material_details()
 		self.validate_materials()
+		self.set_routing_operations()
 		self.validate_operations()
 		self.calculate_cost()
 		self.update_cost(update_parent=False, from_child_bom=True, save=False)
@@ -111,18 +113,13 @@
 	def get_routing(self):
 		if self.routing:
 			self.set("operations", [])
-			for d in frappe.get_all("BOM Operation", fields = ["*"],
-				filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="idx"):
-				child = self.append('operations', {
-					"operation": d.operation,
-					"workstation": d.workstation,
-					"description": d.description,
-					"time_in_mins": d.time_in_mins,
-					"batch_size": d.batch_size,
-					"operating_cost": d.operating_cost,
-					"idx": d.idx
-				})
-				child.hour_rate = flt(d.hour_rate / self.conversion_rate, 2)
+			fields = ["sequence_id", "operation", "workstation", "description",
+				"time_in_mins", "batch_size", "operating_cost", "idx", "hour_rate"]
+
+			for row in frappe.get_all("BOM Operation", fields = fields,
+				filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="sequence_id, idx"):
+				child = self.append('operations', row)
+				child.hour_rate = flt(row.hour_rate / self.conversion_rate, 2)
 
 	def set_bom_material_details(self):
 		for item in self.get("items"):
@@ -571,6 +568,10 @@
 			if act_pbom and act_pbom[0][0]:
 				frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs"))
 
+	def set_routing_operations(self):
+		if self.routing and self.with_operations and not self.operations:
+			self.get_routing()
+
 	def validate_operations(self):
 		if self.with_operations and not self.get('operations'):
 			frappe.throw(_("Operations cannot be left blank"))
diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
index 0350e2c..07464e3 100644
--- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
+++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
@@ -1,10 +1,12 @@
 {
+ "actions": [],
  "creation": "2013-02-22 01:27:49",
  "doctype": "DocType",
  "document_type": "Setup",
  "editable_grid": 1,
  "engine": "InnoDB",
  "field_order": [
+  "sequence_id",
   "operation",
   "workstation",
   "description",
@@ -106,11 +108,19 @@
    "fieldname": "batch_size",
    "fieldtype": "Int",
    "label": "Batch Size"
+  },
+  {
+   "depends_on": "eval:doc.parenttype == \"Routing\"",
+   "fieldname": "sequence_id",
+   "fieldtype": "Int",
+   "label": "Sequence ID"
   }
  ],
  "idx": 1,
+ "index_web_pages_for_search": 1,
  "istable": 1,
- "modified": "2020-06-16 17:01:11.128420",
+ "links": [],
+ "modified": "2020-10-13 18:14:10.018774",
  "modified_by": "Administrator",
  "module": "Manufacturing",
  "name": "BOM Operation",
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json
index 087ab6b..575e719 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.json
+++ b/erpnext/manufacturing/doctype/job_card/job_card.json
@@ -36,6 +36,7 @@
   "items",
   "more_information",
   "operation_id",
+  "sequence_id",
   "transferred_qty",
   "requested_qty",
   "column_break_20",
@@ -297,10 +298,18 @@
    "fieldname": "operation_row_number",
    "fieldtype": "Select",
    "label": "Operation Row Number"
+  },
+  {
+   "fieldname": "sequence_id",
+   "fieldtype": "Int",
+   "label": "Sequence Id",
+   "print_hide": 1,
+   "read_only": 1
   }
  ],
  "is_submittable": 1,
- "modified": "2020-08-24 15:21:21.398267",
+ "links": [],
+ "modified": "2020-10-14 12:58:25.327897",
  "modified_by": "Administrator",
  "module": "Manufacturing",
  "name": "Job Card",
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 8855e0a..4dfa78b 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -5,7 +5,7 @@
 from __future__ import unicode_literals
 import frappe
 import datetime
-from frappe import _
+from frappe import _, bold
 from frappe.model.mapper import get_mapped_doc
 from frappe.model.document import Document
 from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate,
@@ -16,12 +16,14 @@
 class OverlapError(frappe.ValidationError): pass
 
 class OperationMismatchError(frappe.ValidationError): pass
+class OperationSequenceError(frappe.ValidationError): pass
 
 class JobCard(Document):
 	def validate(self):
 		self.validate_time_logs()
 		self.set_status()
 		self.validate_operation_id()
+		self.validate_sequence_id()
 
 	def validate_time_logs(self):
 		self.total_completed_qty = 0.0
@@ -196,14 +198,14 @@
 	def validate_job_card(self):
 		if not self.time_logs:
 			frappe.throw(_("Time logs are required for {0} {1}")
-				.format(frappe.bold("Job Card"), get_link_to_form("Job Card", self.name)))
+				.format(bold("Job Card"), get_link_to_form("Job Card", self.name)))
 
 		if self.for_quantity and self.total_completed_qty != self.for_quantity:
-			total_completed_qty = frappe.bold(_("Total Completed Qty"))
-			qty_to_manufacture = frappe.bold(_("Qty to Manufacture"))
+			total_completed_qty = bold(_("Total Completed Qty"))
+			qty_to_manufacture = bold(_("Qty to Manufacture"))
 
-			frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})"
-				.format(total_completed_qty, frappe.bold(self.total_completed_qty), qty_to_manufacture,frappe.bold(self.for_quantity))))
+			frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})")
+				.format(total_completed_qty, bold(self.total_completed_qty), qty_to_manufacture,bold(self.for_quantity)))
 
 	def update_work_order(self):
 		if not self.work_order:
@@ -213,10 +215,7 @@
 		from_time_list, to_time_list = [], []
 
 		field = "operation_id"
-		data = frappe.get_all('Job Card',
-			fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"],
-			filters = {"docstatus": 1, "work_order": self.work_order, field: self.get(field)})
-
+		data = self.get_current_operation_data()
 		if data and len(data) > 0:
 			for_quantity = data[0].completed_qty
 			time_in_mins = data[0].time_in_mins
@@ -246,6 +245,11 @@
 			wo.set_actual_dates()
 			wo.save()
 
+	def get_current_operation_data(self):
+		return frappe.get_all('Job Card',
+			fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"],
+			filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id})
+
 	def set_transferred_qty(self, update_status=False):
 		if not self.items:
 			self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0
@@ -310,9 +314,32 @@
 	def validate_operation_id(self):
 		if (self.get("operation_id") and self.get("operation_row_number") and self.operation and self.work_order and
 			frappe.get_cached_value("Work Order Operation", self.operation_row_number, "name") != self.operation_id):
-			work_order = frappe.bold(get_link_to_form("Work Order", self.work_order))
+			work_order = bold(get_link_to_form("Work Order", self.work_order))
 			frappe.throw(_("Operation {0} does not belong to the work order {1}")
-				.format(frappe.bold(self.operation), work_order), OperationMismatchError)
+				.format(bold(self.operation), work_order), OperationMismatchError)
+
+	def validate_sequence_id(self):
+		if not (self.work_order and self.sequence_id): return
+
+		current_operation_qty = 0.0
+		data = self.get_current_operation_data()
+		if data and len(data) > 0:
+			current_operation_qty = flt(data[0].completed_qty)
+
+		current_operation_qty += flt(self.total_completed_qty)
+
+		data = frappe.get_all("Work Order Operation",
+			fields = ["operation", "status", "completed_qty"],
+			filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ('<', self.sequence_id)},
+			order_by = "sequence_id, idx")
+
+		message = "Job Card {0}: As per the sequence of the operations in the work order {1}".format(bold(self.name),
+			bold(get_link_to_form("Work Order", self.work_order)))
+
+		for row in data:
+			if row.status != "Completed" and row.completed_qty < current_operation_qty:
+				frappe.throw(_("{0}, complete the operation {1} before the operation {2}.")
+					.format(message, bold(row.operation), bold(self.operation)), OperationSequenceError)
 
 @frappe.whitelist()
 def get_operation_details(work_order, operation):
diff --git a/erpnext/manufacturing/doctype/operation/test_operation.py b/erpnext/manufacturing/doctype/operation/test_operation.py
index 17d206a..0067231 100644
--- a/erpnext/manufacturing/doctype/operation/test_operation.py
+++ b/erpnext/manufacturing/doctype/operation/test_operation.py
@@ -9,3 +9,23 @@
 
 class TestOperation(unittest.TestCase):
 	pass
+
+def make_operation(*args, **kwargs):
+	args = args if args else kwargs
+	if isinstance(args, tuple):
+		args = args[0]
+
+	args = frappe._dict(args)
+
+	try:
+		doc = frappe.get_doc({
+			"doctype": "Operation",
+			"name": args.operation,
+			"workstation": args.workstation
+		})
+
+		doc.insert()
+
+		return doc
+	except frappe.DuplicateEntryError:
+		return frappe.get_doc("Operation", args.operation)
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index d020bc8..fa9d080 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -237,7 +237,9 @@
 		'item': args.item,
 		'currency': args.currency or 'USD',
 		'quantity': args.quantity or 1,
-		'company': args.company or '_Test Company'
+		'company': args.company or '_Test Company',
+		'routing': args.routing,
+		'with_operations': args.with_operations or 0
 	})
 
 	for item in args.raw_materials:
diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js
index d7589fa..741a3f0 100644
--- a/erpnext/manufacturing/doctype/routing/routing.js
+++ b/erpnext/manufacturing/doctype/routing/routing.js
@@ -2,6 +2,13 @@
 // For license information, please see license.txt
 
 frappe.ui.form.on('Routing', {
+	setup: function(frm) {
+		frappe.meta.get_docfield("BOM Operation", "sequence_id",
+			frm.doc.name).in_list_view = true;
+
+		frm.fields_dict.operations.grid.refresh();
+	},
+
 	calculate_operating_cost: function(frm, child) {
 		const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2);
 		frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost);
diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py
index ecd0ba8..8312d74 100644
--- a/erpnext/manufacturing/doctype/routing/routing.py
+++ b/erpnext/manufacturing/doctype/routing/routing.py
@@ -3,7 +3,22 @@
 # For license information, please see license.txt
 
 from __future__ import unicode_literals
+import frappe
+from frappe.utils import cint
+from frappe import _
 from frappe.model.document import Document
 
 class Routing(Document):
-	pass
+	def validate(self):
+		self.set_routing_id()
+
+	def set_routing_id(self):
+		sequence_id = 0
+		for row in self.operations:
+			if not row.sequence_id:
+				row.sequence_id = sequence_id + 1
+			elif sequence_id and row.sequence_id and cint(sequence_id) > cint(row.sequence_id):
+				frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}")
+					.format(row.idx, row.sequence_id, sequence_id))
+
+			sequence_id = row.sequence_id
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py
index 53ad152..73d05a6 100644
--- a/erpnext/manufacturing/doctype/routing/test_routing.py
+++ b/erpnext/manufacturing/doctype/routing/test_routing.py
@@ -4,6 +4,88 @@
 from __future__ import unicode_literals
 
 import unittest
+import frappe
+from frappe.test_runner import make_test_records
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.manufacturing.doctype.operation.test_operation import make_operation
+from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError
+from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
+from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
 
 class TestRouting(unittest.TestCase):
-	pass
+	def test_sequence_id(self):
+		item_code = "Test Routing Item - A"
+		operations = [{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 30},
+			{"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 20}]
+
+		make_test_records("UOM")
+
+		setup_operations(operations)
+		routing_doc = create_routing(routing_name="Testing Route", operations=operations)
+		bom_doc = setup_bom(item_code=item_code, routing=routing_doc.name)
+		wo_doc = make_wo_order_test_record(production_item = item_code, bom_no=bom_doc.name)
+
+		for row in routing_doc.operations:
+			self.assertEqual(row.sequence_id, row.idx)
+
+		for data in frappe.get_all("Job Card",
+			filters={"work_order": wo_doc.name}, order_by="sequence_id desc"):
+			job_card_doc = frappe.get_doc("Job Card", data.name)
+			job_card_doc.time_logs[0].completed_qty = 10
+			if job_card_doc.sequence_id != 1:
+				self.assertRaises(OperationSequenceError, job_card_doc.save)
+			else:
+				job_card_doc.save()
+				self.assertEqual(job_card_doc.total_completed_qty, 10)
+
+		wo_doc.cancel()
+		wo_doc.delete()
+
+def setup_operations(rows):
+	for row in rows:
+		make_workstation(row)
+		make_operation(row)
+
+def create_routing(**args):
+	args = frappe._dict(args)
+
+	doc = frappe.new_doc("Routing")
+	doc.update(args)
+
+	if not args.do_not_save:
+		try:
+			for operation in args.operations:
+				doc.append("operations", operation)
+
+			doc.insert()
+		except frappe.DuplicateEntryError:
+			doc = frappe.get_doc("Routing", args.routing_name)
+
+	return doc
+
+def setup_bom(**args):
+	from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+
+	args = frappe._dict(args)
+
+	if not frappe.db.exists('Item', args.item_code):
+		make_item(args.item_code, {
+			'is_stock_item': 1
+		})
+
+	if not args.raw_materials:
+		if not frappe.db.exists('Item', "Test Extra Item 1"):
+			make_item("Test Extra Item N-1", {
+				'is_stock_item': 1,
+			})
+
+		args.raw_materials = ['Test Extra Item N-1']
+
+	name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name')
+	if not name:
+		bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"),
+			routing = args.routing, with_operations=1)
+	else:
+		bom_doc = frappe.get_doc("BOM", name)
+
+	return bom_doc
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 7f8341f..cc93bf9 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -378,7 +378,7 @@
 			select
 				operation, description, workstation, idx,
 				base_hour_rate as hour_rate, time_in_mins,
-				"Pending" as status, parent as bom, batch_size
+				"Pending" as status, parent as bom, batch_size, sequence_id
 			from
 				`tabBOM Operation`
 			where
@@ -865,6 +865,7 @@
 		'bom_no': work_order.bom_no,
 		'project': work_order.project,
 		'company': work_order.company,
+		'sequence_id': row.get("sequence_id"),
 		'wip_warehouse': work_order.wip_warehouse
 	})
 
diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
index 3f5e18e..8c5cde9 100644
--- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
+++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
@@ -8,6 +8,7 @@
   "details",
   "operation",
   "bom",
+  "sequence_id",
   "description",
   "col_break1",
   "completed_qty",
@@ -187,11 +188,19 @@
    "fieldtype": "Int",
    "label": "Batch Size",
    "read_only": 1
+  },
+  {
+   "fieldname": "sequence_id",
+   "fieldtype": "Int",
+   "label": "Sequence ID",
+   "print_hide": 1,
+   "read_only": 1
   }
  ],
+ "index_web_pages_for_search": 1,
  "istable": 1,
  "links": [],
- "modified": "2019-12-03 19:24:29.594189",
+ "modified": "2020-10-14 12:58:49.241252",
  "modified_by": "Administrator",
  "module": "Manufacturing",
  "name": "Work Order Operation",
diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py
index 8266cf7..c6699be 100644
--- a/erpnext/manufacturing/doctype/workstation/test_workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py
@@ -21,17 +21,22 @@
 		self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours,
 			"_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00")
 
-def make_workstation(**args):
+def make_workstation(*args, **kwargs):
+	args = args if args else kwargs
+	if isinstance(args, tuple):
+		args = args[0]
+
 	args = frappe._dict(args)
 
+	workstation_name = args.workstation_name or args.workstation
 	try:
 		doc = frappe.get_doc({
 			"doctype": "Workstation",
-			"workstation_name": args.workstation_name
+			"workstation_name": workstation_name
 		})
 
 		doc.insert()
 
 		return doc
 	except frappe.DuplicateEntryError:
-		return frappe.get_doc("Workstation", args.workstation_name)
\ No newline at end of file
+		return frappe.get_doc("Workstation", workstation_name)
\ No newline at end of file