diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f47858ba9ff3f..96807ab1df6ca 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -163,7 +163,22 @@ def validate_time_logs(self): for row in self.sub_operations: self.total_completed_qty += row.completed_qty +<<<<<<< HEAD def get_overlap_for(self, args, check_next_available_slot=False): +======= + def get_overlap_for(self, args): + time_logs = [] + + time_logs.extend(self.get_time_logs(args, "Job Card Time Log")) + + time_logs.extend(self.get_time_logs(args, "Job Card Scheduled Time")) + + if not time_logs: + return {} + + time_logs = sorted(time_logs, key=lambda x: x.get("to_time")) + +>>>>>>> 75f8464724 (fix: capacity planning issue in the job card (#40092)) production_capacity = 1 jc = frappe.qb.DocType("Job Card") @@ -223,7 +238,11 @@ def get_overlap_for(self, args, check_next_available_slot=False): self.workstation = workstation return None +<<<<<<< HEAD return existing_time_logs[0] if existing_time_logs else None +======= + return time_logs[0] +>>>>>>> 75f8464724 (fix: capacity planning issue in the job card (#40092)) def has_overlap(self, production_capacity, time_logs): overlap = False @@ -262,7 +281,58 @@ def has_overlap(self, production_capacity, time_logs): return True return overlap +<<<<<<< HEAD def get_workstation_based_on_available_slot(self, existing) -> Optional[str]: +======= + def get_time_logs(self, args, doctype): + jc = frappe.qb.DocType("Job Card") + jctl = frappe.qb.DocType(doctype) + + time_conditions = [ + ((jctl.from_time < args.from_time) & (jctl.to_time > args.from_time)), + ((jctl.from_time < args.to_time) & (jctl.to_time > args.to_time)), + ((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)), + ] + + query = ( + frappe.qb.from_(jctl) + .from_(jc) + .select( + jc.name.as_("name"), + jctl.name.as_("row_name"), + jctl.from_time, + jctl.to_time, + jc.workstation, + jc.workstation_type, + ) + .where( + (jctl.parent == jc.name) + & (Criterion.any(time_conditions)) + & (jctl.name != f"{args.name or 'No Name'}") + & (jc.name != f"{args.parent or 'No Name'}") + & (jc.docstatus < 2) + ) + .orderby(jctl.to_time) + ) + + if self.workstation_type: + query = query.where(jc.workstation_type == self.workstation_type) + + if self.workstation: + query = query.where(jc.workstation == self.workstation) + + if args.get("employee") and doctype == "Job Card Time Log": + query = query.where(jctl.employee == args.get("employee")) + + if doctype != "Job Card Time Log": + query = query.where(jc.total_time_in_mins == 0) + + time_logs = query.run(as_dict=True) + + return time_logs + + def get_workstation_based_on_available_slot(self, existing_time_logs) -> dict: +>>>>>>> 75f8464724 (fix: capacity planning issue in the job card (#40092)) workstations = get_workstations(self.workstation_type) if workstations: busy_workstations = [row.workstation for row in existing] @@ -280,12 +350,37 @@ def schedule_time_logs(self, row): def validate_overlap_for_workstation(self, args, row): # get the last record based on the to time from the job card +<<<<<<< HEAD data = self.get_overlap_for(args, check_next_available_slot=True) if data: if not self.workstation: self.workstation = data.workstation row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) +======= + data = self.get_overlap_for(args) + + if not self.workstation: + workstations = get_workstations(self.workstation_type) + if workstations: + # Get the first workstation + self.workstation = workstations[0] + + if not data: + row.planned_start_time = args.from_time + return + + if data: + if data.get("planned_start_time"): + args.planned_start_time = get_datetime(data.planned_start_time) + else: + args.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) + + args.from_time = args.planned_start_time + args.to_time = add_to_date(args.planned_start_time, minutes=row.remaining_time_in_mins) + + self.validate_overlap_for_workstation(args, row) +>>>>>>> 75f8464724 (fix: capacity planning issue in the job card (#40092)) def check_workstation_time(self, row): workstation_doc = frappe.get_cached_doc("Workstation", self.workstation) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 74c2ece6e7e29..4b2994063edfd 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1778,6 +1778,162 @@ def test_op_cost_and_scrap_based_on_sub_assemblies(self): "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 0 ) +<<<<<<< HEAD +======= + @change_settings( + "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} + ) + def test_get_rm_cost_from_consumption_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + rm = make_item(properties={"is_stock_item": 1}).name + fg = make_item(properties={"is_stock_item": 1}).name + + make_stock_entry_test_record( + purpose="Material Receipt", + item_code=rm, + target="Stores - _TC", + qty=10, + basic_rate=100, + ) + make_stock_entry_test_record( + purpose="Material Receipt", + item_code=rm, + target="Stores - _TC", + qty=10, + basic_rate=200, + ) + + bom = make_bom(item=fg, raw_materials=[rm], rate=150).name + wo = make_wo_order_test_record( + production_item=fg, + bom_no=bom, + qty=10, + ) + + mte = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) + mte.items[0].s_warehouse = "Stores - _TC" + mte.insert().submit() + + mce = frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 10)) + mce.insert().submit() + + me = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10)) + me.insert().submit() + + valuation_rate = sum([item.valuation_rate * item.transfer_qty for item in mce.items]) / 10 + self.assertEqual(me.items[0].valuation_rate, valuation_rate) + + def test_capcity_planning_for_workstation(self): + frappe.db.set_single_value( + "Manufacturing Settings", + { + "disable_capacity_planning": 0, + "capacity_planning_for_days": 1, + "mins_between_operations": 10, + }, + ) + + properties = {"is_stock_item": 1, "valuation_rate": 100} + fg_item = make_item("Test FG Item For Capacity Planning", properties).name + + rm_item = make_item("Test RM Item For Capacity Planning", properties).name + + workstation = "Test Workstation For Capacity Planning" + if not frappe.db.exists("Workstation", workstation): + make_workstation(workstation=workstation, production_capacity=1) + + operation = "Test Operation For Capacity Planning" + if not frappe.db.exists("Operation", operation): + make_operation(operation=operation, workstation=workstation) + + bom_doc = make_bom( + item=fg_item, + source_warehouse="Stores - _TC", + raw_materials=[rm_item], + with_operations=1, + do_not_submit=True, + ) + + bom_doc.append( + "operations", + {"operation": operation, "time_in_mins": 1420, "hour_rate": 100, "workstation": workstation}, + ) + bom_doc.submit() + + # 1st Work Order, + # Capacity to run parallel the operation 'Test Operation For Capacity Planning' is 2 + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + wo_doc.submit() + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name}, + ) + + self.assertEqual(len(job_cards), 1) + + # 2nd Work Order, + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + wo_doc.submit() + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name}, + ) + + self.assertEqual(len(job_cards), 1) + + # 3rd Work Order, capacity is full + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + self.assertRaises(CapacityError, wo_doc.submit) + + frappe.db.set_single_value( + "Manufacturing Settings", {"disable_capacity_planning": 1, "mins_between_operations": 0} + ) + + +def make_operation(**kwargs): + kwargs = frappe._dict(kwargs) + + operation_doc = frappe.get_doc( + { + "doctype": "Operation", + "name": kwargs.operation, + "workstation": kwargs.workstation, + } + ) + operation_doc.insert() + + return operation_doc + + +def make_workstation(**kwargs): + kwargs = frappe._dict(kwargs) + + workstation_doc = frappe.get_doc( + { + "doctype": "Workstation", + "workstation_name": kwargs.workstation, + "workstation_type": kwargs.workstation_type, + "production_capacity": kwargs.production_capacity or 0, + "hour_rate": kwargs.hour_rate or 100, + } + ) + workstation_doc.insert() + + return workstation_doc + +>>>>>>> 75f8464724 (fix: capacity planning issue in the job card (#40092)) def prepare_boms_for_sub_assembly_test(): if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f95b4f66a334b..77a0c54c7347d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -172,8 +172,12 @@ def validate_warehouse_belongs_to_company(self): def calculate_operating_cost(self): self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0 for d in self.get("operations"): - d.planned_operating_cost = flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0) - d.actual_operating_cost = flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0) + d.planned_operating_cost = flt( + flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost") + ) + d.actual_operating_cost = flt( + flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost") + ) self.planned_operating_cost += flt(d.planned_operating_cost) self.actual_operating_cost += flt(d.actual_operating_cost) @@ -489,7 +493,6 @@ def create_job_card(self): def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning): self.set_operation_start_end_time(index, row) - original_start_time = row.planned_start_time job_card_doc = create_job_card( self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning ) @@ -498,11 +501,15 @@ def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_plann row.planned_start_time = job_card_doc.time_logs[-1].from_time row.planned_end_time = job_card_doc.time_logs[-1].to_time - if date_diff(row.planned_start_time, original_start_time) > plan_days: + if date_diff(row.planned_end_time, self.planned_start_date) > plan_days: frappe.message_log.pop() frappe.throw( - _("Unable to find the time slot in the next {0} days for the operation {1}.").format( - plan_days, row.operation + _( + "Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}." + ).format( + plan_days, + row.operation, + get_link_to_form("Manufacturing Settings", "Manufacturing Settings"), ), CapacityError, )