Skip to content

Commit

Permalink
fix: capacity planning issue in the job card (#40092)
Browse files Browse the repository at this point in the history
* fix: capacity planning issue in the job card

* test: test case to test capacity planning for workstation

(cherry picked from commit 75f8464)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.py
#	erpnext/manufacturing/doctype/work_order/test_work_order.py
  • Loading branch information
rohitwaghchaure authored and mergify[bot] committed Feb 25, 2024
1 parent b3735d2 commit cd9f02c
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 6 deletions.
95 changes: 95 additions & 0 deletions erpnext/manufacturing/doctype/job_card/job_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions erpnext/manufacturing/doctype/work_order/test_work_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}):
Expand Down
19 changes: 13 additions & 6 deletions erpnext/manufacturing/doctype/work_order/work_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
Expand All @@ -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,
)
Expand Down

0 comments on commit cd9f02c

Please sign in to comment.