From 93bbc52a689da046dfa4c921af464042df40ff4c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 23 Oct 2020 18:18:03 +0530 Subject: [PATCH] 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 --- erpnext/manufacturing/doctype/bom/bom.py | 31 +++---- .../doctype/bom_operation/bom_operation.json | 12 ++- .../doctype/job_card/job_card.json | 11 ++- .../doctype/job_card/job_card.py | 51 ++++++++--- .../doctype/operation/test_operation.py | 20 +++++ .../production_plan/test_production_plan.py | 4 +- .../manufacturing/doctype/routing/routing.js | 7 ++ .../manufacturing/doctype/routing/routing.py | 17 +++- .../doctype/routing/test_routing.py | 84 ++++++++++++++++++- .../doctype/work_order/work_order.py | 3 +- .../work_order_operation.json | 11 ++- .../doctype/workstation/test_workstation.py | 11 ++- 12 files changed, 225 insertions(+), 37 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 71d49a9537d..2ab1b987077 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -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}. -
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, "
")) 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")) diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 0350e2cb374..07464e3e766 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 087ab6b484b..575e7190430 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 8855e0acf59..4dfa78bf217 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 @@ 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): diff --git a/erpnext/manufacturing/doctype/operation/test_operation.py b/erpnext/manufacturing/doctype/operation/test_operation.py index 17d206a4e1f..00672317018 100644 --- a/erpnext/manufacturing/doctype/operation/test_operation.py +++ b/erpnext/manufacturing/doctype/operation/test_operation.py @@ -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) \ 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 d020bc83fa3..fa9d080cca4 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 @@ 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: diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index d7589fa3907..741a3f01fdc 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 ecd0ba8be8b..8312d7436c2 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 53ad1527325..73d05a61570 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 7f8341f4c2b..cc93bf9fd63 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -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 }) 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 3f5e18e8130..8c5cde9a13c 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 8266cf7b779..c6699bee48c 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -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) \ No newline at end of file + return frappe.get_doc("Workstation", workstation_name) \ No newline at end of file