diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 040c487e668..802b68d3ba7 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -205,10 +205,14 @@ class JobCard(Document):
self.is_paused = 0
def set_manufactured_qty(self):
- table_name = "Stock Entry"
- child_name = "Stock Entry Detail"
- if self.is_subcontracted:
- table_name = "Subcontracting Receipt Item"
+ self.manufactured_qty = flt(self.get_manufactured_qty())
+ self.db_set("manufactured_qty", self.manufactured_qty)
+
+ self.update_semi_finished_good_details()
+ self.set_status(update_status=True)
+
+ def get_manufactured_qty(self):
+ table_name = "Subcontracting Receipt Item" if self.is_subcontracted else "Stock Entry"
table = frappe.qb.DocType(table_name)
query = frappe.qb.from_(table).where((table.job_card == self.name) & (table.docstatus == 1))
@@ -216,7 +220,7 @@ class JobCard(Document):
if self.is_subcontracted:
query = query.select(Sum(table.qty))
else:
- child = frappe.qb.DocType(child_name)
+ child = frappe.qb.DocType("Stock Entry Detail")
query = (
query.join(child)
.on(table.name == child.parent)
@@ -224,27 +228,27 @@ class JobCard(Document):
.where((table.purpose == "Manufacture") & (child.is_finished_item == 1))
)
- qty = query.run()[0][0] or 0.0
- self.manufactured_qty = flt(qty)
- self.db_set("manufactured_qty", self.manufactured_qty)
-
- self.update_semi_finished_good_details()
- self.set_status(update_status=True)
+ return query.run()[0][0] or 0.0
def validate_job_card_qty(self):
if not (self.operation_id and self.work_order):
return
- wo_qty = flt(frappe.get_cached_value("Work Order", self.work_order, "qty"))
-
+ wo_qty = self.get_allowed_wo_qty()
completed_qty = flt(frappe.db.get_value("Work Order Operation", self.operation_id, "completed_qty"))
+ job_card_qty = self.get_total_job_card_qty()
+ if job_card_qty and ((job_card_qty - completed_qty) > wo_qty):
+ self.throw_extra_qty_error()
+
+ def get_allowed_wo_qty(self):
+ wo_qty = flt(frappe.get_cached_value("Work Order", self.work_order, "qty"))
over_production_percentage = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)
+ return wo_qty + (wo_qty * over_production_percentage / 100)
- wo_qty = wo_qty + (wo_qty * over_production_percentage / 100)
-
+ def get_total_job_card_qty(self):
job_card_qty = frappe.get_all(
"Job Card",
fields=[{"SUM": "for_quantity"}],
@@ -255,17 +259,16 @@ class JobCard(Document):
},
as_list=1,
)
+ return flt(job_card_qty[0][0]) if job_card_qty else 0
- job_card_qty = flt(job_card_qty[0][0]) if job_card_qty else 0
-
- if job_card_qty and ((job_card_qty - completed_qty) > wo_qty):
- form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
- frappe.throw(
- _(
- "Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}.
Solution: Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}."
- ).format(bold(self.operation), form_link),
- title=_("Extra Job Card Quantity"),
- )
+ def throw_extra_qty_error(self):
+ form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
+ frappe.throw(
+ _(
+ "Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}.
Solution: Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}."
+ ).format(bold(self.operation), form_link),
+ title=_("Extra Job Card Quantity"),
+ )
def set_sub_operations(self):
if not self.sub_operations and self.operation:
@@ -292,62 +295,75 @@ class JobCard(Document):
fetch_secondary_items=1,
)
for item_code, values in items_dict.items():
- values = frappe._dict(values)
- secondary_item = {
- "item_code": item_code,
- "stock_qty": values.qty,
- "item_name": values.item_name,
- "stock_uom": values.stock_uom,
- "secondary_item_type": values.secondary_item_type,
- "bom_secondary_item": values.name,
- }
+ self.append_secondary_item(item_code, frappe._dict(values))
- if not values.is_legacy:
- secondary_item["stock_qty"] -= flt(
- secondary_item["stock_qty"] * (values.process_loss_per / 100),
- self.precision("for_quantity"),
- )
+ def append_secondary_item(self, item_code, values):
+ secondary_item = {
+ "item_code": item_code,
+ "stock_qty": values.qty,
+ "item_name": values.item_name,
+ "stock_uom": values.stock_uom,
+ "secondary_item_type": values.secondary_item_type,
+ "bom_secondary_item": values.name,
+ }
- self.append("secondary_items", secondary_item)
+ if not values.is_legacy:
+ secondary_item["stock_qty"] -= flt(
+ secondary_item["stock_qty"] * (values.process_loss_per / 100),
+ self.precision("for_quantity"),
+ )
+
+ self.append("secondary_items", secondary_item)
def validate_time_logs(self, save=False):
self.total_time_in_mins = 0.0
self.total_completed_qty = 0.0
- if self.get("time_logs"):
- for d in self.get("time_logs"):
- if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time):
- frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx))
+ if not self.get("time_logs"):
+ return
- open_job_cards = []
- if d.get("employee"):
- open_job_cards = self.get_open_job_cards(d.get("employee"), workstation=self.workstation)
+ for d in self.get("time_logs"):
+ self.validate_time_log_row(d)
- data = self.get_overlap_for(d, open_job_cards=open_job_cards)
- if data:
- frappe.throw(
- _("Row {0}: From Time and To Time of {1} is overlapping with {2}").format(
- d.idx, self.name, data.name
- ),
- OverlapError,
- )
+ self.total_completed_qty = flt(self.total_completed_qty, self.precision("total_completed_qty"))
- if d.from_time and d.to_time:
- d.time_in_mins = time_diff_in_hours(d.to_time, d.from_time) * 60
- self.total_time_in_mins += d.time_in_mins
+ if save and self.docstatus == 1:
+ self.save_time_log_totals()
- if d.completed_qty and not self.sub_operations:
- self.total_completed_qty += d.completed_qty
+ def validate_time_log_row(self, d):
+ if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time):
+ frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx))
- self.total_completed_qty = flt(self.total_completed_qty, self.precision("total_completed_qty"))
+ self.validate_overlap_in_time_log(d)
- if save and self.docstatus == 1:
- self.db_set(
- {
- "total_time_in_mins": self.total_time_in_mins,
- "total_completed_qty": self.total_completed_qty,
- }
- )
+ if d.from_time and d.to_time:
+ d.time_in_mins = time_diff_in_hours(d.to_time, d.from_time) * 60
+ self.total_time_in_mins += d.time_in_mins
+
+ if d.completed_qty and not self.sub_operations:
+ self.total_completed_qty += d.completed_qty
+
+ def validate_overlap_in_time_log(self, d):
+ open_job_cards = []
+ if d.get("employee"):
+ open_job_cards = self.get_open_job_cards(d.get("employee"), workstation=self.workstation)
+
+ data = self.get_overlap_for(d, open_job_cards=open_job_cards)
+ if data:
+ frappe.throw(
+ _("Row {0}: From Time and To Time of {1} is overlapping with {2}").format(
+ d.idx, self.name, data.name
+ ),
+ OverlapError,
+ )
+
+ def save_time_log_totals(self):
+ self.db_set(
+ {
+ "total_time_in_mins": self.total_time_in_mins,
+ "total_completed_qty": self.total_completed_qty,
+ }
+ )
def set_total_completed_qty_from_sub_operations(self):
sub_op_total_qty = []
@@ -358,32 +374,15 @@ class JobCard(Document):
self.total_completed_qty = min(sub_op_total_qty)
def get_overlap_for(self, args, open_job_cards=None):
- 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", open_job_cards=open_job_cards))
-
+ time_logs = self.get_overlapping_time_logs(args, open_job_cards)
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
- )
+ self.validate_employee_overlap(args)
- if self.get_open_job_cards(args.get("employee")):
- frappe.throw(
- _(
- "Employee {0} is currently working on another workstation. Please assign another employee."
- ).format(args.get("employee")),
- OverlapError,
- )
-
- if not self.has_overlap(production_capacity, time_logs):
+ if not self.has_overlap(self.get_production_capacity(), time_logs):
return {}
if not self.workstation and self.workstation_type and time_logs:
@@ -393,56 +392,81 @@ class JobCard(Document):
return time_logs[0]
+ def get_overlapping_time_logs(self, args, open_job_cards=None):
+ 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", open_job_cards=open_job_cards))
+ return time_logs
+
+ def get_production_capacity(self):
+ if self.workstation:
+ return frappe.get_cached_value("Workstation", self.workstation, "production_capacity") or 1
+ return 1
+
+ def validate_employee_overlap(self, args):
+ if self.get_open_job_cards(args.get("employee")):
+ frappe.throw(
+ _(
+ "Employee {0} is currently working on another workstation. Please assign another employee."
+ ).format(args.get("employee")),
+ OverlapError,
+ )
+
def has_overlap(self, production_capacity, time_logs):
- overlap = False
if production_capacity == 1 and len(time_logs) >= 1:
return True
if not len(time_logs):
return False
+ alloted_capacity = self.get_alloted_capacity(time_logs)
+ # if number of keys is greater or equal to production capacity, full capacity is utilized -> overlap
+ return len(alloted_capacity) >= production_capacity
+
+ def get_alloted_capacity(self, time_logs):
# sorting overlapping job cards as per from_time
time_logs = sorted(time_logs, key=lambda x: x.get("from_time"))
- # alloted_capacity has key number starting from 1. Key number will increment by 1 if non sequential job card found
- # if key number reaches/crosses to production_capacity means capacity is full and overlap error generated
- # this will store last to_time of sequential job cards
+ # alloted_capacity has key number starting from 1. Key number increments by 1 if a non sequential job card found.
+ # Each key stores the last to_time of its sequential job cards.
alloted_capacity = {1: time_logs[0]["to_time"]}
for i in range(1, len(time_logs)):
sequential_job_card_found = False
- # scanning for all Existing keys
for key in alloted_capacity.keys():
- # if current Job Card from time is greater than last to_time in that key means these job card are sequential
+ # if current job card from_time is >= last to_time in that key, these job cards are sequential
if alloted_capacity[key] <= time_logs[i]["from_time"]:
- # So update key's value with last to_time
alloted_capacity[key] = time_logs[i]["to_time"]
- # flag is true as we get sequential Job Card for that key
sequential_job_card_found = True
- # Immediately break so that job card to time is not added with any other key except this
break
- # if sequential job card not found above means it is overlapping so increment key number to alloted_capacity
+ # if no sequential job card found above, it is overlapping -> open a new capacity slot
if not sequential_job_card_found:
- # increment key number
key = key + 1
- # for that key last to time is assigned.
alloted_capacity[key] = time_logs[i]["to_time"]
- if len(alloted_capacity) >= production_capacity:
- # if number of keys greater or equal to production caoacity means full capacity is utilized and we should throw overlap error
- return True
- return overlap
+ return alloted_capacity
def get_time_logs(self, args, doctype, open_job_cards=None):
if args.get("remaining_time_in_mins") and get_datetime(args.from_time) >= get_datetime(args.to_time):
args.to_time = add_to_date(args.from_time, minutes=args.get("remaining_time_in_mins"))
+ if args.get("employee") and not open_job_cards and doctype == "Job Card Scheduled Time":
+ return []
+
jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType(doctype)
+ query = self.get_base_overlap_query(jc, jctl, args)
+ query = self.apply_workstation_filters(query, jc)
+ query = self.apply_employee_filters(query, jc, jctl, args, doctype, open_job_cards)
+ query = self.apply_docstatus_filters(query, jc, doctype)
+
+ return query.run(as_dict=True)
+
+ def get_base_overlap_query(self, jc, jctl, args):
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 = (
+ return (
frappe.qb.from_(jctl)
.from_(jc)
.select(
@@ -463,29 +487,31 @@ class JobCard(Document):
.orderby(jctl.to_time)
)
+ def apply_workstation_filters(self, query, jc):
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"):
- if not open_job_cards and doctype == "Job Card Scheduled Time":
- return []
+ return query
+ def apply_employee_filters(self, query, jc, jctl, args, doctype, open_job_cards):
+ if args.get("employee"):
if doctype == "Job Card Time Log":
query = query.where(jctl.employee == args.get("employee"))
else:
query = query.where(jc.name.isin(open_job_cards))
+ return query
+
+ def apply_docstatus_filters(self, query, jc, doctype):
if doctype == "Job Card Time Log":
query = query.where(jc.docstatus < 2)
else:
query = query.where((jc.docstatus == 0) & (jc.total_time_in_mins == 0))
- time_logs = query.run(as_dict=True)
-
- return time_logs
+ return query
def get_open_job_cards(self, employee, workstation=None):
jc = frappe.qb.DocType("Job Card")
@@ -581,17 +607,28 @@ class JobCard(Document):
if not workstation_doc.working_hours or cint(
frappe.db.get_single_value("Manufacturing Settings", "allow_overtime")
):
- if get_datetime(row.planned_end_time) <= get_datetime(row.planned_start_time):
- row.planned_end_time = add_to_date(row.planned_start_time, minutes=row.time_in_mins)
- row.remaining_time_in_mins = 0.0
- else:
- row.remaining_time_in_mins -= time_diff_in_minutes(
- row.planned_end_time, row.planned_start_time
- )
-
- self.update_time_logs(row)
+ self.schedule_without_working_hours(row)
return
+ start_date = self.adjust_start_date_for_holiday(workstation_doc, row)
+ start_date = self.fit_row_in_working_hours(workstation_doc, row, start_date)
+
+ if row.remaining_time_in_mins > 0:
+ start_date = add_days(start_date, 1)
+ row.planned_start_time = datetime.datetime.combine(
+ start_date, get_time(workstation_doc.working_hours[0].start_time)
+ )
+
+ def schedule_without_working_hours(self, row):
+ if get_datetime(row.planned_end_time) <= get_datetime(row.planned_start_time):
+ row.planned_end_time = add_to_date(row.planned_start_time, minutes=row.time_in_mins)
+ row.remaining_time_in_mins = 0.0
+ else:
+ row.remaining_time_in_mins -= time_diff_in_minutes(row.planned_end_time, row.planned_start_time)
+
+ self.update_time_logs(row)
+
+ def adjust_start_date_for_holiday(self, workstation_doc, row):
start_date = getdate(row.planned_start_time)
start_time = get_time(row.planned_start_time)
@@ -601,77 +638,83 @@ class JobCard(Document):
row.planned_start_time = datetime.datetime.combine(new_start_date, start_time)
start_date = new_start_date
+ return start_date
+
+ def fit_row_in_working_hours(self, workstation_doc, row, start_date):
total_idx = len(workstation_doc.working_hours)
for i, time_slot in enumerate(workstation_doc.working_hours):
workstation_start_time = datetime.datetime.combine(start_date, get_time(time_slot.start_time))
workstation_end_time = datetime.datetime.combine(start_date, get_time(time_slot.end_time))
- if (
+ if not (
get_datetime(row.planned_start_time) >= workstation_start_time
and get_datetime(row.planned_start_time) <= workstation_end_time
):
- time_in_mins = time_diff_in_minutes(workstation_end_time, row.planned_start_time)
+ continue
- # If remaining time fit in workstation time logs else split hours as per workstation time
- if time_in_mins > row.remaining_time_in_mins:
- row.planned_end_time = add_to_date(
- row.planned_start_time, minutes=row.remaining_time_in_mins
- )
- row.remaining_time_in_mins = 0
- else:
- row.planned_end_time = add_to_date(row.planned_start_time, minutes=time_in_mins)
- row.remaining_time_in_mins -= time_in_mins
+ self.consume_working_hour_slot(row, workstation_end_time)
- self.update_time_logs(row)
+ if total_idx != (i + 1) and row.remaining_time_in_mins > 0:
+ row.planned_start_time = datetime.datetime.combine(
+ start_date, get_time(workstation_doc.working_hours[i + 1].start_time)
+ )
- if total_idx != (i + 1) and row.remaining_time_in_mins > 0:
- row.planned_start_time = datetime.datetime.combine(
- start_date, get_time(workstation_doc.working_hours[i + 1].start_time)
- )
+ return start_date
- if row.remaining_time_in_mins > 0:
- start_date = add_days(start_date, 1)
- row.planned_start_time = datetime.datetime.combine(
- start_date, get_time(workstation_doc.working_hours[0].start_time)
- )
+ def consume_working_hour_slot(self, row, workstation_end_time):
+ time_in_mins = time_diff_in_minutes(workstation_end_time, row.planned_start_time)
+
+ # If remaining time fits in workstation time logs else split hours as per workstation time
+ if time_in_mins > row.remaining_time_in_mins:
+ row.planned_end_time = add_to_date(row.planned_start_time, minutes=row.remaining_time_in_mins)
+ row.remaining_time_in_mins = 0
+ else:
+ row.planned_end_time = add_to_date(row.planned_start_time, minutes=time_in_mins)
+ row.remaining_time_in_mins -= time_in_mins
+
+ self.update_time_logs(row)
def add_time_log(self, args):
- last_row = []
employees = args.employees
if isinstance(employees, str):
employees = json.loads(employees)
- if self.time_logs and len(self.time_logs) > 0:
- last_row = self.time_logs[-1]
+ last_row = self.time_logs[-1] if self.time_logs and len(self.time_logs) > 0 else []
if last_row and args.get("complete_time"):
- for row in self.time_logs:
- if not row.to_time:
- to_time = get_datetime(args.get("complete_time"))
- row.db_set(
- {
- "to_time": to_time,
- "time_in_mins": time_diff_in_minutes(to_time, row.from_time),
- "operation": args.get("sub_operation"),
- "completed_qty": (args.get("completed_qty") if last_row.idx == row.idx else 0.0),
- }
- )
+ self.complete_open_time_logs(args, last_row)
elif args.get("start_time"):
- new_args = frappe._dict(
- {
- "from_time": get_datetime(args.get("start_time")),
- "operation": args.get("sub_operation"),
- "completed_qty": 0.0,
- }
- )
+ self.add_start_time_logs(args, employees)
- if employees:
- for name in employees:
- new_args.employee = name.get("employee")
- self.add_start_time_log(new_args)
- else:
+ def complete_open_time_logs(self, args, last_row):
+ for row in self.time_logs:
+ if not row.to_time:
+ to_time = get_datetime(args.get("complete_time"))
+ row.db_set(
+ {
+ "to_time": to_time,
+ "time_in_mins": time_diff_in_minutes(to_time, row.from_time),
+ "operation": args.get("sub_operation"),
+ "completed_qty": (args.get("completed_qty") if last_row.idx == row.idx else 0.0),
+ }
+ )
+
+ def add_start_time_logs(self, args, employees):
+ new_args = frappe._dict(
+ {
+ "from_time": get_datetime(args.get("start_time")),
+ "operation": args.get("sub_operation"),
+ "completed_qty": 0.0,
+ }
+ )
+
+ if employees:
+ for name in employees:
+ new_args.employee = name.get("employee")
self.add_start_time_log(new_args)
+ else:
+ self.add_start_time_log(new_args)
def add_start_time_log(self, args):
if args.from_time and args.to_time:
@@ -684,6 +727,18 @@ class JobCard(Document):
if not self.sub_operations:
return
+ operation_wise_completed_time = self.get_operation_wise_completed_time()
+
+ for row in self.sub_operations:
+ operation_deatils = operation_wise_completed_time.get(row.sub_operation)
+ if operation_deatils:
+ self.set_sub_operation_from_details(row, operation_deatils)
+ else:
+ row.status = "Pending"
+ row.completed_time = 0.0
+ row.completed_qty = 0.0
+
+ def get_operation_wise_completed_time(self):
operation_wise_completed_time = {}
for time_log in self.time_logs:
if time_log.operation not in operation_wise_completed_time:
@@ -704,24 +759,18 @@ class JobCard(Document):
op_row.completed_time += time_log.time_in_mins
op_row.completed_qty += flt(time_log.completed_qty)
- for row in self.sub_operations:
- operation_deatils = operation_wise_completed_time.get(row.sub_operation)
- if operation_deatils:
- if row.status != "Complete":
- row.status = operation_deatils.status
+ return operation_wise_completed_time
- row.completed_time = operation_deatils.completed_time
- if operation_deatils.employee:
- row.completed_time = row.completed_time / len(set(operation_deatils.employee))
+ def set_sub_operation_from_details(self, row, operation_deatils):
+ if row.status != "Complete":
+ row.status = operation_deatils.status
- if operation_deatils.completed_qty:
- row.completed_qty = operation_deatils.completed_qty / len(
- set(operation_deatils.employee)
- )
- else:
- row.status = "Pending"
- row.completed_time = 0.0
- row.completed_qty = 0.0
+ row.completed_time = operation_deatils.completed_time
+ if operation_deatils.employee:
+ row.completed_time = row.completed_time / len(set(operation_deatils.employee))
+
+ if operation_deatils.completed_qty:
+ row.completed_qty = operation_deatils.completed_qty / len(set(operation_deatils.employee))
def update_time_logs(self, row):
self.append(
@@ -736,6 +785,8 @@ class JobCard(Document):
@frappe.whitelist()
def get_required_items(self):
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+
if not self.get("work_order"):
return
@@ -746,31 +797,36 @@ class JobCard(Document):
return
for d in doc.required_items:
- if not doc.track_semi_finished_goods and not d.operation and not d.operation_row_id:
- frappe.throw(
- _("Row {0} : Operation is required against the raw material item {1}").format(
- d.idx, d.item_code
- )
- )
+ self.append_required_item(doc, d)
- if (
- self.get("operation") == d.operation
- or self.operation_row_id == d.operation_row_id
- or self.is_corrective_job_card
- ):
- self.append(
- "items",
- {
- "item_code": d.item_code,
- "source_warehouse": self.source_warehouse or d.source_warehouse,
- "uom": frappe.db.get_value("Item", d.item_code, "stock_uom"),
- "item_name": d.item_name,
- "description": d.description,
- "required_qty": (d.required_qty * flt(self.for_quantity)) / doc.qty,
- "rate": d.rate,
- "amount": d.amount,
- },
+ def append_required_item(self, doc, d):
+ if not doc.track_semi_finished_goods and not d.operation and not d.operation_row_id:
+ frappe.throw(
+ _("Row {0} : Operation is required against the raw material item {1}").format(
+ d.idx, d.item_code
)
+ )
+
+ if not (
+ self.get("operation") == d.operation
+ or self.operation_row_id == d.operation_row_id
+ or self.is_corrective_job_card
+ ):
+ return
+
+ self.append(
+ "items",
+ {
+ "item_code": d.item_code,
+ "source_warehouse": self.source_warehouse or d.source_warehouse,
+ "uom": frappe.db.get_value("Item", d.item_code, "stock_uom"),
+ "item_name": d.item_name,
+ "description": d.description,
+ "required_qty": (d.required_qty * flt(self.for_quantity)) / doc.qty,
+ "rate": d.rate,
+ "amount": d.amount,
+ },
+ )
def before_save(self):
self.set_expected_and_actual_time()
@@ -788,64 +844,52 @@ class JobCard(Document):
self.set_transferred_qty()
def validate_inspection(self):
+ bom_inspection_required = frappe.get_value("BOM", self.bom_no, "inspection_required")
+ operation_inspection_required = frappe.get_value(
+ "Work Order Operation", self.operation_id, "quality_inspection_required"
+ )
+ if not (bom_inspection_required and operation_inspection_required):
+ return
+
+ if not self.quality_inspection:
+ frappe.throw(
+ _(
+ "Quality Inspection is required for the item {0} before completing the job card {1}"
+ ).format(get_link_to_form("Item", self.finished_good), bold(self.name))
+ )
+
action_submit, action_reject = frappe.get_single_value(
"Stock Settings",
["action_if_quality_inspection_is_not_submitted", "action_if_quality_inspection_is_rejected"],
)
- bom_inspection_required = frappe.get_value("BOM", self.bom_no, "inspection_required")
- operation_inspection_required = frappe.get_value(
- "Work Order Operation", self.operation_id, "quality_inspection_required"
+ qa_status, docstatus = frappe.get_value(
+ "Quality Inspection", self.quality_inspection, ["status", "docstatus"]
)
- if bom_inspection_required and operation_inspection_required:
- if not self.quality_inspection:
- frappe.throw(
- _(
- "Quality Inspection is required for the item {0} before completing the job card {1}"
- ).format(get_link_to_form("Item", self.finished_good), bold(self.name))
- )
+ if docstatus != 1:
+ self.handle_unsubmitted_inspection(action_submit)
+ elif qa_status == "Rejected":
+ self.handle_rejected_inspection(action_reject)
- qa_status, docstatus = frappe.get_value(
- "Quality Inspection", self.quality_inspection, ["status", "docstatus"]
- )
- if docstatus != 1:
- if action_submit == "Stop":
- frappe.throw(
- _("Quality Inspection {0} is not submitted for the item: {1}").format(
- get_link_to_form("Quality Inspection", self.quality_inspection),
- get_link_to_form("Item", self.finished_good),
- ),
- title=_("Inspection Submission"),
- exc=QualityInspectionNotSubmittedError,
- )
- else:
- frappe.msgprint(
- _("Quality Inspection {0} is not submitted for the item: {1}").format(
- get_link_to_form("Quality Inspection", self.quality_inspection),
- get_link_to_form("Item", self.finished_good),
- ),
- alert=True,
- indicator="orange",
- )
- elif qa_status == "Rejected":
- if action_reject == "Stop":
- frappe.throw(
- _("Quality Inspection {0} is rejected for the item: {1}").format(
- get_link_to_form("Quality Inspection", self.quality_inspection),
- get_link_to_form("Item", self.finished_good),
- ),
- title=_("Inspection Rejected"),
- exc=QualityInspectionRejectedError,
- )
- else:
- frappe.msgprint(
- _("Quality Inspection {0} is rejected for the item: {1}").format(
- get_link_to_form("Quality Inspection", self.quality_inspection),
- get_link_to_form("Item", self.finished_good),
- ),
- alert=True,
- indicator="orange",
- )
+ def handle_unsubmitted_inspection(self, action_submit):
+ message = _("Quality Inspection {0} is not submitted for the item: {1}").format(
+ get_link_to_form("Quality Inspection", self.quality_inspection),
+ get_link_to_form("Item", self.finished_good),
+ )
+ if action_submit == "Stop":
+ frappe.throw(message, title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError)
+ else:
+ frappe.msgprint(message, alert=True, indicator="orange")
+
+ def handle_rejected_inspection(self, action_reject):
+ message = _("Quality Inspection {0} is rejected for the item: {1}").format(
+ get_link_to_form("Quality Inspection", self.quality_inspection),
+ get_link_to_form("Item", self.finished_good),
+ )
+ if action_reject == "Stop":
+ frappe.throw(message, title=_("Inspection Rejected"), exc=QualityInspectionRejectedError)
+ else:
+ frappe.msgprint(message, alert=True, indicator="orange")
def validate_transfer_qty(self):
if (
@@ -871,6 +915,10 @@ class JobCard(Document):
)
)
+ self.validate_time_logs_present()
+ self.validate_completed_qty_matches_for_quantity()
+
+ def validate_time_logs_present(self):
if not self.time_logs:
frappe.throw(
_("Time logs are required for {0} {1}").format(
@@ -884,6 +932,7 @@ class JobCard(Document):
_("Row #{0}: From Time and To Time fields are required").format(row.idx),
)
+ def validate_completed_qty_matches_for_quantity(self):
precision = self.precision("total_completed_qty")
total_completed_qty = flt(
flt(self.total_completed_qty, precision)
@@ -892,14 +941,11 @@ class JobCard(Document):
)
if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision):
- total_completed_qty_label = bold(_("Total Completed Qty"))
- qty_to_manufacture = bold(_("Qty to Manufacture"))
-
frappe.throw(
_("The {0} ({1}) must be equal to {2} ({3})").format(
- total_completed_qty_label,
+ bold(_("Total Completed Qty")),
bold(flt(total_completed_qty, precision)),
- qty_to_manufacture,
+ bold(_("Qty to Manufacture")),
bold(self.for_quantity),
)
)
@@ -912,23 +958,28 @@ class JobCard(Document):
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 self.set_time_for_child_table(child_table, start_field, end_field, time_required):
+ return
- 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
+ def set_time_for_child_table(self, child_table, start_field, end_field, time_required):
+ 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)))
- self.set(end_field, max(time_list))
+ 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 True
- self.set(time_required, time_in_mins)
+ self.set(end_field, max(time_list))
+
+ self.set(time_required, time_in_mins)
+ return False
def set_process_loss(self):
precision = self.precision("total_completed_qty")
@@ -952,14 +1003,7 @@ class JobCard(Document):
):
return
- for_quantity, time_in_mins, process_loss_qty, pending_qty = 0, 0, 0, 0
-
- data = self.get_current_operation_data()
- if data and len(data) > 0:
- for_quantity = flt(data[0].completed_qty)
- time_in_mins = flt(data[0].time_in_mins)
- process_loss_qty = flt(data[0].process_loss_qty)
- pending_qty = flt(data[0].pending_qty)
+ for_quantity, time_in_mins, process_loss_qty, pending_qty = self.get_operation_totals()
wo = frappe.get_doc("Work Order", self.work_order)
@@ -970,6 +1014,18 @@ class JobCard(Document):
self.validate_produced_quantity(for_quantity, process_loss_qty, pending_qty, wo)
self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo)
+ def get_operation_totals(self):
+ for_quantity, time_in_mins, process_loss_qty, pending_qty = 0, 0, 0, 0
+
+ data = self.get_current_operation_data()
+ if data and len(data) > 0:
+ for_quantity = flt(data[0].completed_qty)
+ time_in_mins = flt(data[0].time_in_mins)
+ process_loss_qty = flt(data[0].process_loss_qty)
+ pending_qty = flt(data[0].pending_qty)
+
+ return for_quantity, time_in_mins, process_loss_qty, pending_qty
+
def update_semi_finished_good_details(self):
if self.operation_id:
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty))
@@ -1015,11 +1071,29 @@ class JobCard(Document):
)
def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo):
- workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate")
+ time_data = self.get_operation_time_data()
+
+ for data in wo.operations:
+ if data.get("name") == self.operation_id:
+ self.update_wo_operation_row(
+ data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data
+ )
+
+ wo.flags.ignore_validate_update_after_submit = True
+ wo.update_operation_status()
+ wo.calculate_operating_cost()
+ wo.set_actual_dates()
+
+ if time_data:
+ wo.status = "In Process"
+
+ wo.save()
+
+ def get_operation_time_data(self):
jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType("Job Card Time Log")
- time_data = (
+ return (
frappe.qb.from_(jc)
.from_(jctl)
.select(Min(jctl.from_time).as_("start_time"), Max(jctl.to_time).as_("end_time"))
@@ -1032,28 +1106,19 @@ class JobCard(Document):
)
).run(as_dict=True)
- for data in wo.operations:
- if data.get("name") == self.operation_id:
- data.completed_qty = for_quantity
- data.process_loss_qty = process_loss_qty
- data.pending_qty = pending_qty
- data.actual_operation_time = time_in_mins
- data.actual_start_time = time_data[0].start_time if time_data else None
- data.actual_end_time = time_data[0].end_time if time_data else None
- if data.get("workstation") != self.workstation:
- # workstations can change in a job card
- data.workstation = self.workstation
- data.hour_rate = flt(workstation_hour_rate)
-
- wo.flags.ignore_validate_update_after_submit = True
- wo.update_operation_status()
- wo.calculate_operating_cost()
- wo.set_actual_dates()
-
- if time_data:
- wo.status = "In Process"
-
- wo.save()
+ def update_wo_operation_row(
+ self, data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data
+ ):
+ data.completed_qty = for_quantity
+ data.process_loss_qty = process_loss_qty
+ data.pending_qty = pending_qty
+ data.actual_operation_time = time_in_mins
+ data.actual_start_time = time_data[0].start_time if time_data else None
+ data.actual_end_time = time_data[0].end_time if time_data else None
+ if data.get("workstation") != self.workstation:
+ # workstations can change in a job card
+ data.workstation = self.workstation
+ data.hour_rate = flt(frappe.get_value("Workstation", self.workstation, "hour_rate"))
def get_current_operation_data(self):
return frappe.get_all(
@@ -1103,49 +1168,7 @@ class JobCard(Document):
frappe.db.set_value("Job Card Item", row.job_card_item, "consumed_qty", consumed_qty)
def set_transferred_qty_in_job_card_item(self, ste_doc):
- def _get_job_card_items_transferred_qty(ste_doc):
- from frappe.query_builder.functions import Sum
-
- job_card_items_transferred_qty = {}
- job_card_items = [x.get("job_card_item") for x in ste_doc.get("items") if x.get("job_card_item")]
-
- if job_card_items:
- se = frappe.qb.DocType("Stock Entry")
- sed = frappe.qb.DocType("Stock Entry Detail")
-
- query = (
- frappe.qb.from_(sed)
- .join(se)
- .on(sed.parent == se.name)
- .select(sed.job_card_item, Sum(sed.qty))
- .where(
- (sed.job_card_item.isin(job_card_items))
- & (se.docstatus == 1)
- & (se.purpose == "Material Transfer for Manufacture")
- )
- .groupby(sed.job_card_item)
- )
-
- job_card_items_transferred_qty = frappe._dict(query.run(as_list=True))
-
- return job_card_items_transferred_qty
-
- def _validate_over_transfer(row, transferred_qty):
- "Block over transfer of items if not allowed in settings."
- required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
- is_excess = flt(transferred_qty) > flt(required_qty)
- if is_excess:
- frappe.throw(
- _(
- "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
- ).format(
- row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
- ),
- title=_("Excess Transfer"),
- exc=JobCardOverTransferError,
- )
-
- job_card_items_transferred_qty = _get_job_card_items_transferred_qty(ste_doc) or {}
+ job_card_items_transferred_qty = self.get_job_card_items_transferred_qty(ste_doc) or {}
allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
for row in ste_doc.items:
@@ -1155,16 +1178,62 @@ class JobCard(Document):
transferred_qty = flt(job_card_items_transferred_qty.get(row.job_card_item, 0.0))
if not allow_excess:
- _validate_over_transfer(row, transferred_qty)
+ self.validate_over_transfer(ste_doc, row, transferred_qty)
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
+ def get_job_card_items_transferred_qty(self, ste_doc):
+ from frappe.query_builder.functions import Sum
+
+ job_card_items = [x.get("job_card_item") for x in ste_doc.get("items") if x.get("job_card_item")]
+ if not job_card_items:
+ return {}
+
+ se = frappe.qb.DocType("Stock Entry")
+ sed = frappe.qb.DocType("Stock Entry Detail")
+
+ query = (
+ frappe.qb.from_(sed)
+ .join(se)
+ .on(sed.parent == se.name)
+ .select(sed.job_card_item, Sum(sed.qty))
+ .where(
+ (sed.job_card_item.isin(job_card_items))
+ & (se.docstatus == 1)
+ & (se.purpose == "Material Transfer for Manufacture")
+ )
+ .groupby(sed.job_card_item)
+ )
+
+ return frappe._dict(query.run(as_list=True))
+
+ def validate_over_transfer(self, ste_doc, row, transferred_qty):
+ "Block over transfer of items if not allowed in settings."
+ required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
+ if flt(transferred_qty) > flt(required_qty):
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
+ ).format(row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card),
+ title=_("Excess Transfer"),
+ exc=JobCardOverTransferError,
+ )
+
def set_transferred_qty(self, update_status=False):
+ self.db_set("transferred_qty", self.get_transferred_qty_from_stock_entry())
+ self.set_status(update_status)
+
+ if self.work_order and not frappe.get_cached_value(
+ "Work Order", self.work_order, "track_semi_finished_goods"
+ ):
+ self.set_transferred_qty_in_work_order()
+
+ def get_transferred_qty_from_stock_entry(self):
from frappe.query_builder.functions import Sum
stock_entry = frappe.qb.DocType("Stock Entry")
- query = (
+ result = (
frappe.qb.from_(stock_entry)
.select(Sum(stock_entry.fg_completed_qty))
.where(
@@ -1173,63 +1242,39 @@ class JobCard(Document):
& (stock_entry.purpose == "Material Transfer for Manufacture")
)
.groupby(stock_entry.job_card)
- )
+ ).run()
- query = query.run()
- qty = 0
-
- if query and query[0][0]:
- qty = flt(query[0][0])
-
- self.db_set("transferred_qty", qty)
- self.set_status(update_status)
-
- if self.work_order and not frappe.get_cached_value(
- "Work Order", self.work_order, "track_semi_finished_goods"
- ):
- self.set_transferred_qty_in_work_order()
+ return flt(result[0][0]) if result and result[0][0] else 0
def set_transferred_qty_in_work_order(self):
doc = frappe.get_doc("Work Order", self.work_order)
- qty = 0.0
if doc.transfer_material_against == "Job Card" and not doc.skip_transfer:
- min_qty = []
- for d in doc.operations:
- completed_qty = flt(d.completed_qty) + flt(d.process_loss_qty)
- if completed_qty:
- min_qty.append(completed_qty)
- else:
- min_qty = []
- break
-
- if min_qty:
- qty = min(min_qty)
-
+ qty = self.get_min_completed_operation_qty(doc)
doc.db_set("material_transferred_for_manufacturing", qty)
+ def get_min_completed_operation_qty(self, doc):
+ min_qty = []
+ for d in doc.operations:
+ completed_qty = flt(d.completed_qty) + flt(d.process_loss_qty)
+ if completed_qty:
+ min_qty.append(completed_qty)
+ else:
+ min_qty = []
+ break
+
+ return min(min_qty) if min_qty else 0.0
+
def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.finished_good and self.docstatus == 1:
- if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
- self.status = "Completed"
- elif self.transferred_qty > 0 or self.skip_material_transfer:
- self.status = "Work In Progress"
+ self.set_finished_good_status()
if self.docstatus == 0 and self.time_logs:
self.status = "Work In Progress"
if not self.track_semi_finished_goods and self.docstatus < 2:
- if flt(self.for_quantity) <= flt(self.transferred_qty):
- self.status = "Material Transferred"
-
- if self.time_logs:
- self.status = "Work In Progress"
-
- if self.docstatus == 1 and (
- self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
- ):
- self.status = "Completed"
+ self.set_non_semi_fg_status()
if self.is_paused:
self.status = "On Hold"
@@ -1240,6 +1285,24 @@ class JobCard(Document):
if self.workstation:
self.update_workstation_status()
+ def set_finished_good_status(self):
+ if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
+ self.status = "Completed"
+ elif self.transferred_qty > 0 or self.skip_material_transfer:
+ self.status = "Work In Progress"
+
+ def set_non_semi_fg_status(self):
+ if flt(self.for_quantity) <= flt(self.transferred_qty):
+ self.status = "Material Transferred"
+
+ if self.time_logs:
+ self.status = "Work In Progress"
+
+ if self.docstatus == 1 and (
+ self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
+ ):
+ self.status = "Completed"
+
def set_wip_warehouse(self):
if not self.wip_warehouse:
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
@@ -1263,6 +1326,8 @@ class JobCard(Document):
@frappe.whitelist()
def pause_job(self, **kwargs):
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -1271,6 +1336,8 @@ class JobCard(Document):
@frappe.whitelist()
def resume_job(self, **kwargs):
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -1282,61 +1349,56 @@ class JobCard(Document):
)
def validate_sequence_id(self):
- if self.is_new():
- return
-
- if self.is_corrective_job_card:
+ if self.is_new() or self.is_corrective_job_card:
return
if not (self.work_order and self.sequence_id):
return
- current_operation_qty = 0.0
- data = self.get_current_operation_data()
- if data and len(data) > 0:
- current_operation_qty = flt(data[0].completed_qty)
+ current_operation_qty = self.get_current_operation_completed_qty()
- current_operation_qty += flt(self.total_completed_qty)
-
- data = frappe.get_all(
+ previous_operations = frappe.get_all(
"Work Order Operation",
fields=["operation", "status", "completed_qty", "sequence_id"],
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
order_by="sequence_id, idx",
)
+ for row in previous_operations:
+ self.validate_previous_operation(row, current_operation_qty)
+
+ def get_current_operation_completed_qty(self):
+ current_operation_qty = 0.0
+ data = self.get_current_operation_data()
+ if data and len(data) > 0:
+ current_operation_qty = flt(data[0].completed_qty)
+
+ return current_operation_qty + flt(self.total_completed_qty)
+
+ def validate_previous_operation(self, row, current_operation_qty):
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
)
- for row in data:
- if not row.completed_qty:
- frappe.throw(
- _("{0}, complete the operation {1} before the operation {2}.").format(
- message, bold(row.operation), bold(self.operation)
- ),
- OperationSequenceError,
- )
+ if not row.completed_qty or (row.status != "Completed" and row.completed_qty < current_operation_qty):
+ frappe.throw(
+ _("{0}, complete the operation {1} before the operation {2}.").format(
+ message, bold(row.operation), bold(self.operation)
+ ),
+ OperationSequenceError,
+ )
- if row.status != "Completed" and row.completed_qty < current_operation_qty:
- frappe.throw(
- _("{0}, complete the operation {1} before the operation {2}.").format(
- message, bold(row.operation), bold(self.operation)
- ),
- OperationSequenceError,
- )
-
- if row.completed_qty < current_operation_qty:
- frappe.throw(
- _(
- "The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
- ).format(
- bold(current_operation_qty),
- bold(self.operation),
- bold(row.completed_qty),
- bold(row.operation),
- )
+ if row.completed_qty < current_operation_qty:
+ frappe.throw(
+ _(
+ "The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
+ ).format(
+ bold(current_operation_qty),
+ bold(self.operation),
+ bold(row.completed_qty),
+ bold(row.operation),
)
+ )
def validate_work_order(self):
if self.is_work_order_closed():
@@ -1381,44 +1443,52 @@ class JobCard(Document):
self.save()
def add_time_logs_for_employess(self, kwargs):
- row = None
update_status = False
for employee in kwargs.employees:
kwargs.employee = employee.get("employee")
if kwargs.from_time and not kwargs.to_time:
- if kwargs.qty:
- kwargs.completed_qty = kwargs.qty
-
- row = self.append("time_logs", kwargs)
- row.db_update()
- self.db_set("status", "Work In Progress")
+ self.add_new_time_log_for_employee(kwargs)
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
+ self.update_completed_qty_for_employee(kwargs)
update_status = True
- for row in self.time_logs:
- if row.employee != kwargs.employee:
- continue
-
- row.completed_qty = kwargs.completed_qty
- row.db_update()
else:
+ self.close_time_log_for_employee(kwargs)
update_status = True
- for row in self.time_logs:
- if row.to_time or row.employee != kwargs.employee:
- continue
-
- row.to_time = kwargs.to_time
- row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
- if kwargs.get("sub_operation"):
- row.operation = kwargs.get("sub_operation")
-
- if kwargs.employees[-1].get("employee") == row.employee:
- row.completed_qty = kwargs.completed_qty
-
- row.db_update()
self.set_status(update_status=update_status)
+ def add_new_time_log_for_employee(self, kwargs):
+ if kwargs.qty:
+ kwargs.completed_qty = kwargs.qty
+
+ row = self.append("time_logs", kwargs)
+ row.db_update()
+ self.db_set("status", "Work In Progress")
+
+ def update_completed_qty_for_employee(self, kwargs):
+ for row in self.time_logs:
+ if row.employee != kwargs.employee:
+ continue
+
+ row.completed_qty = kwargs.completed_qty
+ row.db_update()
+
+ def close_time_log_for_employee(self, kwargs):
+ for row in self.time_logs:
+ if row.to_time or row.employee != kwargs.employee:
+ continue
+
+ row.to_time = kwargs.to_time
+ row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
+ if kwargs.get("sub_operation"):
+ row.operation = kwargs.get("sub_operation")
+
+ if kwargs.employees[-1].get("employee") == row.employee:
+ row.completed_qty = kwargs.completed_qty
+
+ row.db_update()
+
def update_workstation_status(self):
status_map = {
"Open": "Off",
@@ -1443,6 +1513,8 @@ class JobCard(Document):
@frappe.whitelist()
def start_timer(self, **kwargs):
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -1454,9 +1526,22 @@ class JobCard(Document):
@frappe.whitelist()
def complete_job_card(self, **kwargs):
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
+ self.validate_complete_job_card_qty(kwargs)
+
+ self.pending_qty = flt(kwargs.pending_qty)
+ self.process_loss_qty = flt(kwargs.process_loss_qty)
+
+ self.add_completion_time_logs(kwargs)
+
+ if kwargs.auto_submit:
+ self.auto_submit_job_card(kwargs.auto_submit)
+
+ def validate_complete_job_card_qty(self, kwargs):
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
frappe.throw(_("Pending quantity cannot be negative."))
@@ -1466,9 +1551,7 @@ class JobCard(Document):
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
- self.pending_qty = flt(kwargs.pending_qty)
- self.process_loss_qty = flt(kwargs.process_loss_qty)
-
+ def add_completion_time_logs(self, kwargs):
if kwargs.end_time:
self.add_time_logs(
to_time=kwargs.end_time,
@@ -1483,38 +1566,50 @@ class JobCard(Document):
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
self.save()
- if kwargs.auto_submit:
- self.submit()
+ def auto_submit_job_card(self, auto_submit):
+ self.submit()
- if not self.finished_good:
- return
+ if not self.finished_good:
+ return
- self.make_stock_entry_for_semi_fg_item(kwargs.auto_submit)
- frappe.msgprint(
- _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name))
- )
+ self.make_stock_entry_for_semi_fg_item(auto_submit)
+ frappe.msgprint(_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)))
@frappe.whitelist()
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
- from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
+ frappe.has_permission("Job Card", "write", doc=self, throw=True)
+ frappe.has_permission("Stock Entry", "create", throw=True)
- def get_consumed_process_loss():
- table = frappe.qb.DocType("Stock Entry")
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.process_loss_qty))
- .where(
- (table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
- )
- )
- return query.run()[0][0] or 0
+ ste = self.build_manufacture_stock_entry()
+ self.populate_manufacture_stock_entry(ste)
+ if auto_submit:
+ ste.stock_entry.submit()
+ else:
+ ste.stock_entry.save()
+
+ frappe.msgprint(
+ _("Stock Entry {0} has created").format(get_link_to_form("Stock Entry", ste.stock_entry.name))
+ )
+
+ return ste.stock_entry.as_dict()
+
+ def get_consumed_process_loss(self):
+ table = frappe.qb.DocType("Stock Entry")
+ query = (
+ frappe.qb.from_(table)
+ .select(Sum(table.process_loss_qty))
+ .where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
+ )
+ return query.run()[0][0] or 0
+
+ def build_manufacture_stock_entry(self):
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
- ste = ManufactureEntry(
+ return ManufactureEntry(
{
"for_quantity": self.for_quantity - self.manufactured_qty,
- "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
+ "process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
@@ -1529,6 +1624,9 @@ class JobCard(Document):
}
)
+ def populate_manufacture_stock_entry(self, ste):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
+
ste.make_stock_entry()
ste.stock_entry.flags.ignore_mandatory = True
wo_doc = frappe.get_doc("Work Order", self.work_order)
@@ -1538,17 +1636,6 @@ class JobCard(Document):
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse
- if auto_submit:
- ste.stock_entry.submit()
- else:
- ste.stock_entry.save()
-
- frappe.msgprint(
- _("Stock Entry {0} has created").format(get_link_to_form("Stock Entry", ste.stock_entry.name))
- )
-
- return ste.stock_entry.as_dict()
-
@frappe.whitelist()
def make_time_log(kwargs: str | dict):
@@ -1557,6 +1644,7 @@ def make_time_log(kwargs: str | dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("Job Card", kwargs.job_card_id)
+ frappe.has_permission("Job Card", "write", doc=doc, throw=True)
doc.validate_sequence_id()
doc.add_time_log(kwargs)
doc.set_status(update_status=True)
@@ -1564,6 +1652,8 @@ def make_time_log(kwargs: str | dict):
@frappe.whitelist()
def get_operation_details(work_order: str, operation: str):
+ frappe.has_permission("Work Order", "read", throw=True)
+
if work_order and operation:
return frappe.get_all(
"Work Order Operation",
@@ -1574,6 +1664,8 @@ def get_operation_details(work_order: str, operation: str):
@frappe.whitelist()
def get_operations(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
+ frappe.has_permission("Work Order", "read", throw=True)
+
if not filters.get("work_order"):
frappe.msgprint(_("Please select a Work Order first."))
return []
@@ -1597,49 +1689,128 @@ def time_diff_in_minutes(string_ed_date, string_st_date):
return time_diff(string_ed_date, string_st_date).total_seconds() / 60
+# Maps a filter operator to a callable that builds the query builder condition
+# for a given column and value. Avoids a long if/elif chain.
+FILTER_OPERATORS = {
+ "=": lambda column, value: column == value,
+ "!=": lambda column, value: column != value,
+ ">": lambda column, value: column > value,
+ "<": lambda column, value: column < value,
+ ">=": lambda column, value: column >= value,
+ "<=": lambda column, value: column <= value,
+ "like": lambda column, value: column.like(value),
+ "not like": lambda column, value: column.not_like(value),
+ "in": lambda column, value: column.isin(value),
+ "not in": lambda column, value: column.notin(value),
+ "between": lambda column, value: column.between(value[0], value[1]),
+ "is": lambda column, value: (
+ (column.isnotnull() & (column != "")) if value == "set" else (column.isnull() | (column == ""))
+ ),
+}
+
+
+def get_job_card_filter_conditions(jc, filters):
+ """Build query builder conditions for the calendar filters on the Job Card table.
+
+ Replaces the previous raw SQL ``get_filters_cond`` based filtering so that all
+ user supplied values are passed as bound parameters via the query builder.
+ """
+ if isinstance(filters, str):
+ filters = json.loads(filters)
+
+ if not filters:
+ return []
+
+ conditions = []
+ for field, operator, value in normalize_calendar_filters(filters):
+ builder = FILTER_OPERATORS.get((operator or "=").lower())
+ if builder:
+ conditions.append(builder(jc[field], value))
+
+ return conditions
+
+
+def normalize_calendar_filters(filters):
+ """Normalize the supported filter formats into (field, operator, value) tuples."""
+ if isinstance(filters, dict):
+ return normalize_dict_filters(filters)
+
+ normalized = []
+ for f in filters:
+ f = list(f)
+ if len(f) == 4:
+ # [doctype, fieldname, operator, value]
+ normalized.append((f[1], f[2], f[3]))
+ elif len(f) == 3:
+ normalized.append((f[0], f[1], f[2]))
+ elif len(f) == 2:
+ normalized.append((f[0], "=", f[1]))
+
+ return normalized
+
+
+def normalize_dict_filters(filters):
+ normalized = []
+ for field, value in filters.items():
+ if isinstance(value, list | tuple) and len(value) == 2:
+ normalized.append((field, value[0], value[1]))
+ elif isinstance(value, str) and value.startswith("!"):
+ normalized.append((field, "!=", value[1:]))
+ else:
+ normalized.append((field, "=", value))
+
+ return normalized
+
+
@frappe.whitelist()
def get_job_details(start: Any, end: Any, filters: str | dict | None = None):
- events = []
+ if not frappe.has_permission("Job Card", "read"):
+ frappe.throw(_("Not permitted to read Job Card"), frappe.PermissionError)
+ job_cards = get_job_cards_for_calendar(filters)
+
+ return [get_calendar_event(d) for d in job_cards]
+
+
+def get_job_cards_for_calendar(filters):
+ jc = frappe.qb.DocType("Job Card")
+ jctl = frappe.qb.DocType("Job Card Time Log")
+
+ query = (
+ frappe.qb.from_(jc)
+ .from_(jctl)
+ .select(
+ jc.name,
+ jc.work_order,
+ jc.status,
+ IfNull(jc.remarks, "").as_("remarks"),
+ Min(jctl.from_time).as_("from_time"),
+ Max(jctl.to_time).as_("to_time"),
+ )
+ .where(jc.name == jctl.parent)
+ .groupby(jc.name)
+ )
+
+ for condition in get_job_card_filter_conditions(jc, filters):
+ query = query.where(condition)
+
+ return query.run(as_dict=True)
+
+
+def get_calendar_event(d):
event_color = {
"Completed": "#cdf5a6",
"Material Transferred": "#ffdd9e",
"Work In Progress": "#D3D3D3",
}
- from frappe.desk.reportview import get_filters_cond
+ subject_data = [d.get(field) for field in ["name", "work_order", "remarks"] if d.get(field)]
+ color = event_color.get(d.status)
- conditions = get_filters_cond("Job Card", filters, [])
-
- job_cards = frappe.db.sql(
- f""" SELECT `tabJob Card`.name, `tabJob Card`.work_order,
- `tabJob Card`.status, ifnull(`tabJob Card`.remarks, ''),
- min(`tabJob Card Time Log`.from_time) as from_time,
- max(`tabJob Card Time Log`.to_time) as to_time
- FROM `tabJob Card` , `tabJob Card Time Log`
- WHERE
- `tabJob Card`.name = `tabJob Card Time Log`.parent {conditions}
- group by `tabJob Card`.name""",
- as_dict=1,
- )
-
- for d in job_cards:
- subject_data = []
- for field in ["name", "work_order", "remarks"]:
- if not d.get(field):
- continue
-
- subject_data.append(d.get(field))
-
- color = event_color.get(d.status)
- job_card_data = {
- "from_time": d.from_time,
- "to_time": d.to_time,
- "name": d.name,
- "subject": "\n".join(subject_data),
- "color": color if color else "#89bcde",
- }
-
- events.append(job_card_data)
-
- return events
+ return {
+ "from_time": d.from_time,
+ "to_time": d.to_time,
+ "name": d.name,
+ "subject": "\n".join(subject_data),
+ "color": color if color else "#89bcde",
+ }