refactor: separate table added to track scheduling in the job card
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json
index 5d912fa..0f01704 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",
@@ -332,18 +339,10 @@
"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 2c17568..80bdfd5 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 @@
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 @@
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 @@
& (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 @@
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 @@
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 @@
)
def before_save(self):
+ self.set_expected_and_actual_time()
self.set_process_loss()
def on_submit(self):
@@ -510,6 +556,32 @@
)
)
+ 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 e7fbcda..bde0548 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 @@
)[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 @@
)[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 0000000..e69de29
--- /dev/null
+++ b/erpnext/manufacturing/doctype/job_card_scheduled_time/__init__.py
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 0000000..522cfa3
--- /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 0000000..e50b153
--- /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 a37ff28..e069aea 100644
--- a/erpnext/manufacturing/doctype/routing/test_routing.py
+++ b/erpnext/manufacturing/doctype/routing/test_routing.py
@@ -38,6 +38,16 @@
"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 690fe47..c828c87 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 @@
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 @@
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 @@
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 @@
)
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 @@
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 @@
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 bfdcf61..79b1e79 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -519,8 +519,8 @@
)
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 348f4f8..8c1e230 100644
--- a/erpnext/manufacturing/doctype/workstation_type/workstation_type.py
+++ b/erpnext/manufacturing/doctype/workstation_type/workstation_type.py
@@ -20,6 +20,8 @@
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]