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(`
+
`);
+
+ // ── 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)