diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index e096c73cc61..c0cabf50b04 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -298,9 +298,169 @@ 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) { +======= + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log", + args: { args }, + freeze: true, + callback() { + frm.reload_doc(); + frm.trigger("make_dashboard"); + }, + }); + }, + + update_sub_operation(frm, args) { + if (frm.doc.sub_operations?.length) { + const pending_sub_ops = frm.doc.sub_operations.filter((d) => d.status != "Complete"); + if (pending_sub_ops.length) { + args["sub_operation"] = pending_sub_ops[0].sub_operation; + } + } + }, + + make_dashboard(frm, has_items) { + if (frm.doc.__islocal) return false; + + frm.dashboard.refresh(); + + // Clear any previously running timer tick before re-rendering. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } + + const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper); + wrapper.empty(); + + if (frm.doc.docstatus !== 0) { + return; + } + + 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 materials_ready = + doc.skip_material_transfer || + doc.transferred_qty >= doc.for_quantity + doc.process_loss_qty || + !doc.finished_good || + !has_items?.length; + + 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 from_time = frappe.datetime.now_datetime(); + const has_no_employee = !frm.doc.employee || !frm.doc.employee.length; + + if (has_no_employee) { +>>>>>>> 8db1eb0d27 (fix: allow specific methods to run) 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 ae60bcb1ca8..44f1bbde17b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1023,6 +1023,37 @@ class JobCard(Document): OperationMismatchError, ) +<<<<<<< HEAD +======= + @frappe.whitelist() + def pause_job(self, **kwargs): + frappe.has_permission("Job Card", "write", doc=self, throw=True) + + self.validate_docstatus() + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 1) + self.add_time_logs(to_time=kwargs.end_time, completed_qty=0.0, employees=self.employee) + + @frappe.whitelist() + def resume_job(self, **kwargs): + frappe.has_permission("Job Card", "write", doc=self, throw=True) + + self.validate_docstatus() + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 0) + self.add_time_logs( + from_time=kwargs.start_time, + employees=self.employee, + completed_qty=0.0, + ) + +>>>>>>> 8db1eb0d27 (fix: allow specific methods to run) def validate_sequence_id(self): if self.is_corrective_job_card: return @@ -1088,6 +1119,231 @@ 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() + + 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() + + 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_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.")) + + 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 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 + + return ManufactureEntry( + { + "for_quantity": self.for_quantity - self.manufactured_qty, + "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, + "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.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) + 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 + +>>>>>>> 8db1eb0d27 (fix: allow specific methods to run) @frappe.whitelist() def make_time_log(args): diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 1eb47ae577b..499cb2f8259 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -10,6 +10,7 @@ from erpnext.manufacturing.doctype.workstation.workstation import ( NotInWorkingHoursError, WorkstationHolidayError, check_if_within_operating_hours, + update_job_card, ) test_dependencies = ["Warehouse"] @@ -17,7 +18,22 @@ test_records = frappe.get_test_records("Workstation") make_test_records("Workstation") +<<<<<<< HEAD class TestWorkstation(FrappeTestCase): +======= +class TestWorkstation(ERPNextTestSuite): + def test_update_job_card_rejects_disallowed_method(self): + # The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card + # methods. An arbitrary method name must be rejected (PermissionError) before the document + # is even loaded, so this needs no Job Card to exist. + self.assertRaises( + frappe.PermissionError, + update_job_card, + "NON-EXISTENT-JOB-CARD", + "delete", + ) + +>>>>>>> 8db1eb0d27 (fix: allow specific methods to run) def test_validate_timings(self): check_if_within_operating_hours( "_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00" diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 510e69cd272..df5b0205a8e 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -409,3 +409,78 @@ def get_workstations(**kwargs): d.status_image = d.off_status_image return data +<<<<<<< HEAD +======= + + +def get_color_map(): + return { + "Production": "green", + "Off": "gray", + "Idle": "gray", + "Problem": "red", + "Maintenance": "yellow", + "Setup": "blue", + } + + +ALLOWED_JOB_CARD_METHODS = frozenset( + { + "start_timer", + "pause_job", + "resume_job", + "complete_job_card", + } +) + + +@frappe.whitelist() +def update_job_card(job_card: str, method: str, **kwargs): + if method not in ALLOWED_JOB_CARD_METHODS: + frappe.throw( + _("Method {0} is not allowed to be run on a Job Card.").format(bold(method)), + frappe.PermissionError, + title=_("Not Allowed"), + ) + + frappe.has_permission("Job Card", "read", throw=True) + + doc = frappe.get_doc("Job Card", job_card) + + # These methods mutate the Job Card, but frappe.get_doc does not enforce permissions — + # require write access before running anything. + frappe.has_permission("Job Card", "write", doc=doc, throw=True) + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.get("employees"): + kwargs.employees = frappe.parse_json(kwargs.employees) + + if kwargs.qty and isinstance(kwargs.qty, str): + kwargs.qty = flt(kwargs.qty) + + doc.run_method(method, **kwargs) + + +@frappe.whitelist() +def validate_job_card(job_card: str, status: str): + job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) + + current_status = job_card_details.status + if current_status != status: + if status == "Open": + frappe.throw( + _("The job card {0} is in {1} state and you cannot start it again.").format( + job_card, current_status + ) + ) + else: + frappe.throw( + _("The job card {0} is in {1} state and you cannot complete.").format( + job_card, current_status + ) + ) + + return job_card_details.for_quantity +>>>>>>> 8db1eb0d27 (fix: allow specific methods to run)