2
0
mirror of https://github.com/frappe/erpnext.git synced 2024-06-02 18:31:06 +00:00

Merge pull request #35921 from rohitwaghchaure/fixed-seperate-table-for-schedule

refactor: separate table added to track scheduling in the job card.
This commit is contained in:
rohitwaghchaure 2023-06-29 22:24:47 +05:30 committed by GitHub
commit 9f1cf0bbb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 363 additions and 94 deletions

View File

@ -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",

View File

@ -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")

View File

@ -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()

View File

@ -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": []
}

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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]