diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index cc8bdf04176..fd727096116 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -296,9 +296,130 @@ frappe.ui.form.on("Job Card", { prepare_timer_buttons: function (frm) { frm.trigger("make_dashboard"); +<<<<<<< HEAD if (!frm.doc.started_time && !frm.doc.current_time) { frm.add_custom_button(__("Start Job"), () => { if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { +======= + const { doc } = frm; + const { time_logs, status } = doc; + + // ── Determine which action buttons to show ──────────────────────── + const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty; + const pending_transfer = + has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)); + const materials_ready = doc.skip_material_transfer || !pending_transfer; + + let last_row = {}; + const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0; + if (has_sub_ops_or_pending_qty && time_logs?.length) { + last_row = get_last_row(time_logs); + } + + const no_time_logs_yet = !time_logs?.length; + const pending_qty_cycle_done = flt(doc.pending_qty) > 0.0 && last_row?.to_time; + const sub_operation_cycle_done = doc.sub_operations?.length && last_row?.to_time; + const should_show_start = + (no_time_logs_yet || pending_qty_cycle_done || sub_operation_cycle_done) && !doc.is_paused; + + const last_log_complete = time_logs?.length && time_logs[time_logs.length - 1].to_time; + const is_on_hold = status === "On Hold"; + const is_actively_running = !!( + time_logs?.length && + !last_log_complete && + !is_on_hold && + !doc.is_paused + ); + + let show_start = false, + show_pause = false, + show_resume = false, + show_complete = false, + is_timer_running = false; + + if (has_remaining_qty && materials_ready) { + const manufactured_qty = doc.manufactured_qty || doc.total_completed_qty; + const qty_yet_to_manufacture = doc.for_quantity - (manufactured_qty + doc.process_loss_qty); + + if (should_show_start) { + show_start = true; + } else if (doc.is_paused) { + show_resume = true; + } else if (qty_yet_to_manufacture > 0) { + show_pause = true; + show_complete = true; + is_timer_running = true; + } + } + + // ── Timer color reflects job state ──────────────────────────────── + const [timer_color, timer_bg, timer_border] = [ + "var(--gray-600,#6b7280)", + "var(--gray-100,#f3f4f6)", + "var(--gray-300,#d1d5db)", + ]; + + // ── Action button HTML ──────────────────────────────────────────── + const btn = (cls, icon_path, label, icon_color) => ` + `; + + const icons = { + play: { d: '', fill: "currentColor", stroke: "none" }, + pause: { + d: '', + fill: "currentColor", + stroke: "none", + }, + check: { d: '', sw: 3 }, + }; + + const buttons_html = [ + show_start && btn("btn-primary jcd-btn-start", "play", __("Start Job")), + show_resume && btn("btn-primary jcd-btn-resume", "play", __("Resume Job")), + show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")), + show_complete && btn("btn-primary jcd-btn-complete", "check", __("Complete Job"), "white"), + ] + .filter(Boolean) + .join(""); + + // ── Render widget ───────────────────────────────────────────────── + wrapper.append(` +
+
+
+
+ ${__("Elapsed Time")} +
+
+ ${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)} + + 00:00:00 + +
+
+
+ ${buttons_html} +
+
+
`); + + // ── Wire up button click handlers ───────────────────────────────── + if (show_start) { + wrapper.find(".jcd-btn-start").on("click", () => { + const has_no_employee = !frm.doc.employee || !frm.doc.employee.length; + + if (has_no_employee) { + // Capture the start time only when the employee dialog is submitted, not on click, + // so the time spent selecting the operator is not counted as worked time. +>>>>>>> 808b2e2984 (fix: require material transfer before job card start and completion) frappe.prompt( { fieldtype: "Table MultiSelect", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 9fddeda3e96..8f8bc8ee627 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1106,6 +1106,268 @@ class JobCard(Document): frappe.db.set_value("Workstation", self.workstation, "status", status) +<<<<<<< HEAD +======= + def add_time_logs(self, **kwargs): + kwargs = frappe._dict(kwargs) + if not kwargs.employees and kwargs.to_time: + for row in self.time_logs: + if not row.to_time and row.from_time: + row.to_time = kwargs.to_time + row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time) + + if kwargs.completed_qty: + row.completed_qty = kwargs.completed_qty + row.db_update() + else: + self.add_time_logs_for_employess(kwargs) + + self.validate_time_logs(save=True) + self.save() + + def add_time_logs_for_employess(self, kwargs): + update_status = False + + for employee in kwargs.employees: + kwargs.employee = employee.get("employee") + if kwargs.from_time and not kwargs.to_time: + 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 + else: + self.close_time_log_for_employee(kwargs) + update_status = True + + 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", + "Work In Progress": "Production", + "Completed": "Off", + "On Hold": "Idle", + } + + job_cards = frappe.get_all( + "Job Card", + fields=["name", "status"], + filters={"workstation": self.workstation, "docstatus": 0, "status": ("!=", "Completed")}, + order_by="status desc", + ) + + if not job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", "Off") + + for row in job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", status_map.get(row.status)) + return + + @frappe.whitelist() + def start_timer(self, **kwargs): + frappe.has_permission("Job Card", "write", doc=self, throw=True) + + self.validate_docstatus() + self.validate_transfer_qty() + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if isinstance(kwargs.employees, str): + kwargs.employees = [{"employee": kwargs.employees}] + + if kwargs.start_time: + self.add_time_logs(from_time=kwargs.start_time, employees=kwargs.employees) + + @frappe.whitelist() + def complete_job_card(self, **kwargs): + frappe.has_permission("Job Card", "write", doc=self, throw=True) + + self.validate_docstatus() + self.validate_transfer_qty() + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.validate_complete_job_card_qty(kwargs) + self.set_for_quantity(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 set_for_quantity(self, kwargs): + """Qty to Manufacture of the completion dialog covers the current cycle only, + so the qty completed by the earlier cycles of this job card is kept.""" + if not flt(kwargs.for_quantity): + return + + self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity) + + def validate_docstatus(self): + if self.docstatus == 2: + frappe.throw(_("Cancelled Job Card cannot be processed.")) + + if self.docstatus == 1: + frappe.throw(_("Submitted Job Card cannot be processed.")) + + 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.")) + + if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0: + frappe.throw(_("Process loss quantity cannot be negative.")) + + 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.validate_completion_qty_split(kwargs) + + def validate_completion_qty_split(self, kwargs): + if not flt(kwargs.for_quantity): + return + + precision = self.precision("total_completed_qty") + accounted_qty = flt(kwargs.qty) + flt(kwargs.pending_qty) + flt(kwargs.process_loss_qty) + + if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision): + return + + frappe.throw( + _( + "Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})." + ).format( + bold(self.get_qty_with_uom(kwargs.qty)), + bold(self.get_qty_with_uom(kwargs.pending_qty)), + bold(self.get_qty_with_uom(kwargs.process_loss_qty)), + bold(self.get_qty_with_uom(kwargs.for_quantity)), + ) + ) + + def add_completion_time_logs(self, kwargs): + if kwargs.end_time: + self.add_time_logs( + to_time=kwargs.end_time, + completed_qty=kwargs.qty, + employees=self.employee, + sub_operation=kwargs.get("sub_operation"), + ) + + if self.docstatus == 1: + self.update_work_order() + else: + self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee) + self.save() + + def auto_submit_job_card(self, auto_submit): + self.submit() + + if not self.finished_good: + return + + 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): + frappe.has_permission("Job Card", "write", doc=self, throw=True) + frappe.has_permission("Stock Entry", "create", throw=True) + + 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 been 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 + + consumed_process_loss = self.get_consumed_process_loss() + return ManufactureEntry( + { + "for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss, + "process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0), + "job_card": self.name, + "skip_material_transfer": self.skip_material_transfer, + "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, + "work_order": self.work_order, + "purpose": "Manufacture", + "production_item": self.finished_good, + "company": self.company, + "wip_warehouse": self.wip_warehouse, + "fg_warehouse": self.target_warehouse, + "bom_no": self.semi_fg_bom, + "project": frappe.db.get_value("Work Order", self.work_order, "project"), + } + ) + + def populate_manufacture_stock_entry(self, ste): + from erpnext.stock.doctype.stock_entry.services.manufacturing import ManufactureStockEntry + + ste.make_stock_entry() + ste.stock_entry.flags.ignore_mandatory = True + wo_doc = frappe.get_doc("Work Order", self.work_order) + add_additional_cost(ste.stock_entry, wo_doc, self) + ManufactureStockEntry(ste.stock_entry).add_secondary_items_from_job_card() + for row in ste.stock_entry.items: + if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse: + row.t_warehouse = self.target_warehouse + +>>>>>>> 808b2e2984 (fix: require material transfer before job card start and completion) @frappe.whitelist() def make_time_log(args):