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
This commit is contained in:
		
							parent
							
								
									9d94f785e9
								
							
						
					
					
						commit
						93bbc52a68
					
				| @ -55,10 +55,11 @@ class BOM(WebsiteGenerator): | ||||
| 			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 @@ class BOM(WebsiteGenerator): | ||||
| 		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 @@ class BOM(WebsiteGenerator): | ||||
| 	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 @@ class BOM(WebsiteGenerator): | ||||
| 			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")) | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings | ||||
| 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 @@ class JobCard(Document): | ||||
| 	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 @@ class JobCard(Document): | ||||
| 		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 @@ class JobCard(Document): | ||||
| 			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 @@ class JobCard(Document): | ||||
| 	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): | ||||
|  | ||||
| @ -9,3 +9,23 @@ test_records = frappe.get_test_records('Operation') | ||||
| 
 | ||||
| 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) | ||||
| @ -237,7 +237,9 @@ def make_bom(**args): | ||||
| 		'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: | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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 | ||||
| @ -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 | ||||
| @ -378,7 +378,7 @@ class WorkOrder(Document): | ||||
| 			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 @@ def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto | ||||
| 		'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 | ||||
| 	}) | ||||
| 
 | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -21,17 +21,22 @@ class TestWorkstation(unittest.TestCase): | ||||
| 		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) | ||||
| 		return frappe.get_doc("Workstation", workstation_name) | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user