From 497c83eb7e755e374871cddea401a10a7cb80d76 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 14 Jun 2023 22:57:24 +0530 Subject: [PATCH] refactor: separate table added to track scheduling in the job card --- .../doctype/job_card/job_card.json | 150 ++++++++++++------ .../doctype/job_card/job_card.py | 132 +++++++++++---- .../doctype/job_card/test_job_card.py | 29 ++++ .../job_card_scheduled_time/__init__.py | 0 .../job_card_scheduled_time.json | 45 ++++++ .../job_card_scheduled_time.py | 9 ++ .../doctype/routing/test_routing.py | 10 ++ .../doctype/work_order/test_work_order.py | 74 +++++++-- .../doctype/work_order/work_order.py | 4 +- .../workstation_type/workstation_type.py | 4 +- 10 files changed, 363 insertions(+), 94 deletions(-) create mode 100644 erpnext/manufacturing/doctype/job_card_scheduled_time/__init__.py create mode 100644 erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.json create mode 100644 erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.py diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 5d912faca9..0f01704eb0 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -9,39 +9,40 @@ "naming_series", "work_order", "bom_no", + "production_item", + "employee", "column_break_4", "posting_date", "company", - "production_section", - "production_item", - "item_name", "for_quantity", - "serial_and_batch_bundle", - "serial_no", - "column_break_12", - "wip_warehouse", - "quality_inspection_template", - "quality_inspection", - "project", - "batch_no", - "operation_section_section", - "operation", - "operation_row_number", - "column_break_18", - "workstation_type", - "workstation", - "employee", - "section_break_21", - "sub_operations", - "timing_detail", - "expected_start_date", - "expected_end_date", - "time_logs", - "section_break_13", "total_completed_qty", "process_loss_qty", - "column_break_15", + "scheduled_time_section", + "expected_start_date", + "time_required", + "column_break_jkir", + "expected_end_date", + "section_break_05am", + "scheduled_time_logs", + "timing_detail", + "time_logs", + "section_break_13", + "actual_start_date", "total_time_in_mins", + "column_break_15", + "actual_end_date", + "production_section", + "operation", + "wip_warehouse", + "column_break_12", + "workstation_type", + "workstation", + "quality_inspection_section", + "quality_inspection_template", + "column_break_fcmp", + "quality_inspection", + "section_break_21", + "sub_operations", "section_break_8", "items", "scrap_items_section", @@ -53,18 +54,25 @@ "hour_rate", "for_operation", "more_information", - "operation_id", - "sequence_id", + "project", + "item_name", "transferred_qty", "requested_qty", "status", "column_break_20", + "operation_row_number", + "operation_id", + "sequence_id", "remarks", + "serial_and_batch_bundle", + "batch_no", + "serial_no", "barcode", "job_started", "started_time", "current_time", - "amended_from" + "amended_from", + "connections_tab" ], "fields": [ { @@ -134,7 +142,7 @@ { "fieldname": "timing_detail", "fieldtype": "Section Break", - "label": "Timing Detail" + "label": "Actual Time" }, { "allow_bulk_edit": 1, @@ -167,7 +175,7 @@ }, { "fieldname": "section_break_8", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Raw Materials" }, { @@ -179,7 +187,7 @@ { "collapsible": 1, "fieldname": "more_information", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "More Information" }, { @@ -264,10 +272,9 @@ "reqd": 1 }, { - "collapsible": 1, "fieldname": "production_section", - "fieldtype": "Section Break", - "label": "Production" + "fieldtype": "Tab Break", + "label": "Operation & Workstation" }, { "fieldname": "column_break_12", @@ -331,19 +338,11 @@ "options": "Job Card Operation", "read_only": 1 }, - { - "fieldname": "operation_section_section", - "fieldtype": "Section Break", - "label": "Operation Section" - }, - { - "fieldname": "column_break_18", - "fieldtype": "Column Break" - }, { "fieldname": "section_break_21", - "fieldtype": "Section Break", - "hide_border": 1 + "fieldtype": "Tab Break", + "hide_border": 1, + "label": "Sub Operations" }, { "depends_on": "is_corrective_job_card", @@ -355,7 +354,7 @@ "collapsible": 1, "depends_on": "is_corrective_job_card", "fieldname": "corrective_operation_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Corrective Operation" }, { @@ -408,7 +407,7 @@ { "collapsible": 1, "fieldname": "scrap_items_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Scrap Items" }, { @@ -451,15 +450,68 @@ "print_hide": 1 }, { + "depends_on": "process_loss_qty", "fieldname": "process_loss_qty", "fieldtype": "Float", "label": "Process Loss Qty", "read_only": 1 + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "scheduled_time_section", + "fieldtype": "Section Break", + "label": "Scheduled Time" + }, + { + "fieldname": "column_break_jkir", + "fieldtype": "Column Break" + }, + { + "fieldname": "time_required", + "fieldtype": "Float", + "label": "Expected Time Required (In Mins)" + }, + { + "fieldname": "section_break_05am", + "fieldtype": "Section Break" + }, + { + "fieldname": "scheduled_time_logs", + "fieldtype": "Table", + "label": "Scheduled Time Logs", + "options": "Job Card Scheduled Time", + "read_only": 1 + }, + { + "fieldname": "actual_start_date", + "fieldtype": "Datetime", + "label": "Actual Start Date", + "read_only": 1 + }, + { + "fieldname": "actual_end_date", + "fieldtype": "Datetime", + "label": "Actual End Date", + "read_only": 1 + }, + { + "fieldname": "quality_inspection_section", + "fieldtype": "Section Break", + "label": "Quality Inspection" + }, + { + "fieldname": "column_break_fcmp", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2023-06-09 12:04:55.534264", + "modified": "2023-06-28 19:23:14.345214", "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 2c17568d1b..80bdfd5329 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -2,7 +2,7 @@ # For license information, please see license.txt import datetime import json -from typing import Optional +from collections import OrderedDict import frappe from frappe import _, bold @@ -164,10 +164,40 @@ class JobCard(Document): self.total_completed_qty += row.completed_qty def get_overlap_for(self, args, check_next_available_slot=False): - production_capacity = 1 + time_logs = [] + time_logs.extend(self.get_time_logs(args, "Job Card Time Log", check_next_available_slot)) + + time_logs.extend(self.get_time_logs(args, "Job Card Scheduled Time", check_next_available_slot)) + + if not time_logs: + return {} + + time_logs = sorted(time_logs, key=lambda x: x.get("to_time")) + + production_capacity = 1 + if self.workstation: + production_capacity = ( + frappe.get_cached_value("Workstation", self.workstation, "production_capacity") or 1 + ) + + if args.get("employee"): + # override capacity for employee + production_capacity = 1 + + if time_logs and production_capacity > len(time_logs): + return {} + + if self.workstation_type and time_logs: + if workstation_time := self.get_workstation_based_on_available_slot(time_logs): + self.workstation = workstation_time.get("workstation") + return workstation_time + + return time_logs[-1] + + def get_time_logs(self, args, doctype, check_next_available_slot=False): jc = frappe.qb.DocType("Job Card") - jctl = frappe.qb.DocType("Job Card Time Log") + jctl = frappe.qb.DocType(doctype) time_conditions = [ ((jctl.from_time < args.from_time) & (jctl.to_time > args.from_time)), @@ -181,7 +211,7 @@ class JobCard(Document): query = ( frappe.qb.from_(jctl) .from_(jc) - .select(jc.name.as_("name"), jctl.to_time, jc.workstation, jc.workstation_type) + .select(jc.name.as_("name"), jctl.from_time, jctl.to_time, jc.workstation, jc.workstation_type) .where( (jctl.parent == jc.name) & (Criterion.any(time_conditions)) @@ -189,42 +219,51 @@ class JobCard(Document): & (jc.name != f"{args.parent or 'No Name'}") & (jc.docstatus < 2) ) - .orderby(jctl.to_time, order=frappe.qb.desc) + .orderby(jctl.to_time) ) if self.workstation_type: query = query.where(jc.workstation_type == self.workstation_type) if self.workstation: - production_capacity = ( - frappe.get_cached_value("Workstation", self.workstation, "production_capacity") or 1 - ) query = query.where(jc.workstation == self.workstation) - if args.get("employee"): - # override capacity for employee - production_capacity = 1 + if args.get("employee") and doctype == "Job Card Time Log": query = query.where(jctl.employee == args.get("employee")) - existing = query.run(as_dict=True) + if doctype != "Job Card Time Log": + query = query.where(jc.total_time_in_mins == 0) - if existing and production_capacity > len(existing): - return + time_logs = query.run(as_dict=True) - if self.workstation_type: - if workstation := self.get_workstation_based_on_available_slot(existing): - self.workstation = workstation - return None + return time_logs - return existing[0] if existing else None - - def get_workstation_based_on_available_slot(self, existing) -> Optional[str]: + def get_workstation_based_on_available_slot(self, existing_time_logs) -> dict: workstations = get_workstations(self.workstation_type) if workstations: - busy_workstations = [row.workstation for row in existing] - for workstation in workstations: - if workstation not in busy_workstations: - return workstation + busy_workstations = self.time_slot_wise_busy_workstations(existing_time_logs) + for time_slot in busy_workstations: + available_workstations = sorted(list(set(workstations) - set(busy_workstations[time_slot]))) + if available_workstations: + return frappe._dict( + { + "workstation": available_workstations[0], + "planned_start_time": get_datetime(time_slot[0]), + "to_time": get_datetime(time_slot[1]), + } + ) + + return frappe._dict({}) + + @staticmethod + def time_slot_wise_busy_workstations(existing_time_logs) -> dict: + time_slot = OrderedDict() + for row in existing_time_logs: + from_time = get_datetime(row.from_time).strftime("%Y-%m-%d %H:%M") + to_time = get_datetime(row.to_time).strftime("%Y-%m-%d %H:%M") + time_slot.setdefault((from_time, to_time), []).append(row.workstation) + + return time_slot def schedule_time_logs(self, row): row.remaining_time_in_mins = row.time_in_mins @@ -237,11 +276,17 @@ class JobCard(Document): def validate_overlap_for_workstation(self, args, row): # get the last record based on the to time from the job card data = self.get_overlap_for(args, check_next_available_slot=True) - if data: - if not self.workstation: - self.workstation = data.workstation + if not self.workstation: + workstations = get_workstations(self.workstation_type) + if workstations: + # Get the first workstation + self.workstation = workstations[0] - row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) + if data: + if data.get("planned_start_time"): + row.planned_start_time = get_datetime(data.planned_start_time) + else: + row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) def check_workstation_time(self, row): workstation_doc = frappe.get_cached_doc("Workstation", self.workstation) @@ -410,7 +455,7 @@ class JobCard(Document): def update_time_logs(self, row): self.append( - "time_logs", + "scheduled_time_logs", { "from_time": row.planned_start_time, "to_time": row.planned_end_time, @@ -452,6 +497,7 @@ class JobCard(Document): ) def before_save(self): + self.set_expected_and_actual_time() self.set_process_loss() def on_submit(self): @@ -510,6 +556,32 @@ class JobCard(Document): ) ) + def set_expected_and_actual_time(self): + for child_table, start_field, end_field, time_required in [ + ("scheduled_time_logs", "expected_start_date", "expected_end_date", "time_required"), + ("time_logs", "actual_start_date", "actual_end_date", "total_time_in_mins"), + ]: + if not self.get(child_table): + continue + + time_list = [] + time_in_mins = 0.0 + for row in self.get(child_table): + time_in_mins += flt(row.get("time_in_mins")) + for field in ["from_time", "to_time"]: + if row.get(field): + time_list.append(get_datetime(row.get(field))) + + if time_list: + self.set(start_field, min(time_list)) + if end_field == "actual_end_date" and not self.time_logs[-1].to_time: + self.set(end_field, "") + return + + self.set(end_field, max(time_list)) + + self.set(time_required, time_in_mins) + def set_process_loss(self): precision = self.precision("total_completed_qty") diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index e7fbcda7ab..bde05482e2 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -541,6 +541,16 @@ class TestJobCard(FrappeTestCase): )[0].name jc = frappe.get_doc("Job Card", first_job_card) + for row in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) + jc.time_logs[0].completed_qty = 8 jc.save() jc.submit() @@ -557,11 +567,30 @@ class TestJobCard(FrappeTestCase): )[0].name jc2 = frappe.get_doc("Job Card", second_job_card) + for row in jc2.scheduled_time_logs: + jc2.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) jc2.time_logs[0].completed_qty = 10 self.assertRaises(frappe.ValidationError, jc2.save) jc2.load_from_db() + for row in jc2.scheduled_time_logs: + jc2.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) + jc2.time_logs[0].completed_qty = 8 jc2.save() jc2.submit() diff --git a/erpnext/manufacturing/doctype/job_card_scheduled_time/__init__.py b/erpnext/manufacturing/doctype/job_card_scheduled_time/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.json b/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.json new file mode 100644 index 0000000000..522cfa348c --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.json @@ -0,0 +1,45 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-06-14 15:23:54.673262", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "from_time", + "to_time", + "time_in_mins" + ], + "fields": [ + { + "fieldname": "from_time", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "From Time" + }, + { + "fieldname": "to_time", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "To Time" + }, + { + "fieldname": "time_in_mins", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Time (In Mins)" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-06-14 15:27:03.203045", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Job Card Scheduled Time", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.py b/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.py new file mode 100644 index 0000000000..e50b153b9c --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_scheduled_time/job_card_scheduled_time.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class JobCardScheduledTime(Document): + pass diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index a37ff28031..e069aea274 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -38,6 +38,16 @@ class TestRouting(FrappeTestCase): "Job Card", filters={"work_order": wo_doc.name}, order_by="sequence_id desc" ): job_card_doc = frappe.get_doc("Job Card", data.name) + for row in job_card_doc.scheduled_time_logs: + job_card_doc.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) + job_card_doc.time_logs[0].completed_qty = 10 if job_card_doc.sequence_id != 1: self.assertRaises(OperationSequenceError, job_card_doc.save) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 690fe47949..c828c878eb 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -487,6 +487,16 @@ class TestWorkOrder(FrappeTestCase): for i, job_card in enumerate(job_cards): doc = frappe.get_doc("Job Card", job_card) + for row in doc.scheduled_time_logs: + doc.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) + doc.time_logs[0].completed_qty = 1 doc.submit() @@ -957,7 +967,7 @@ class TestWorkOrder(FrappeTestCase): item=item, company=company, planned_start_date=add_days(now(), 60), qty=20, skip_transfer=1 ) job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name") - update_job_card(job_card, 10) + update_job_card(job_card, 10, 1) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: @@ -975,7 +985,7 @@ class TestWorkOrder(FrappeTestCase): make_job_card(wo_order.name, operations) job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name, "docstatus": 0}, "name") - update_job_card(job_card, 10) + update_job_card(job_card, 10, 2) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: @@ -1671,9 +1681,32 @@ class TestWorkOrder(FrappeTestCase): ) job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name") job_card_doc = frappe.get_doc("Job Card", job_card) + for row in job_card_doc.scheduled_time_logs: + job_card_doc.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + "completed_qty": 20, + }, + ) + + job_card_doc.save() # Make another Job Card for the same Work Order job_card2 = frappe.copy_doc(job_card_doc) + job_card2.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "time_in_mins": row.time_in_mins, + }, + ) + + job_card2.time_logs[0].completed_qty = 20 + self.assertRaises(frappe.ValidationError, job_card2.save) frappe.db.set_single_value( @@ -1841,7 +1874,7 @@ def prepare_data_for_backflush_based_on_materials_transferred(): make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name]) -def update_job_card(job_card, jc_qty=None): +def update_job_card(job_card, jc_qty=None, days=None): employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") job_card_doc = frappe.get_doc("Job Card", job_card) job_card_doc.set( @@ -1855,15 +1888,32 @@ def update_job_card(job_card, jc_qty=None): if jc_qty: job_card_doc.for_quantity = jc_qty - job_card_doc.append( - "time_logs", - { - "from_time": now(), - "employee": employee, - "time_in_mins": 60, - "completed_qty": job_card_doc.for_quantity, - }, - ) + for row in job_card_doc.scheduled_time_logs: + job_card_doc.append( + "time_logs", + { + "from_time": row.from_time, + "to_time": row.to_time, + "employee": employee, + "time_in_mins": 60, + "completed_qty": 0.0, + }, + ) + + if not job_card_doc.time_logs and days: + planned_start_time = add_days(now(), days=days) + job_card_doc.append( + "time_logs", + { + "from_time": planned_start_time, + "to_time": add_to_date(planned_start_time, minutes=60), + "employee": employee, + "time_in_mins": 60, + "completed_qty": 0.0, + }, + ) + + job_card_doc.time_logs[0].completed_qty = job_card_doc.for_quantity job_card_doc.submit() diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index bfdcf615c1..79b1e798ed 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -519,8 +519,8 @@ class WorkOrder(Document): ) if enable_capacity_planning and job_card_doc: - row.planned_start_time = job_card_doc.time_logs[-1].from_time - row.planned_end_time = job_card_doc.time_logs[-1].to_time + row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time + row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time if date_diff(row.planned_start_time, original_start_time) > plan_days: frappe.message_log.pop() diff --git a/erpnext/manufacturing/doctype/workstation_type/workstation_type.py b/erpnext/manufacturing/doctype/workstation_type/workstation_type.py index 348f4f8a16..8c1e230af0 100644 --- a/erpnext/manufacturing/doctype/workstation_type/workstation_type.py +++ b/erpnext/manufacturing/doctype/workstation_type/workstation_type.py @@ -20,6 +20,8 @@ class WorkstationType(Document): def get_workstations(workstation_type): - workstations = frappe.get_all("Workstation", filters={"workstation_type": workstation_type}) + workstations = frappe.get_all( + "Workstation", filters={"workstation_type": workstation_type}, order_by="creation" + ) return [workstation.name for workstation in workstations]