fix: allow specific methods to run

(cherry picked from commit 8db1eb0d27)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.js
#	erpnext/manufacturing/doctype/job_card/job_card.py
#	erpnext/manufacturing/doctype/workstation/test_workstation.py
#	erpnext/manufacturing/doctype/workstation/workstation.py
This commit is contained in:
Rohit Waghchaure
2026-06-08 15:41:30 +05:30
committed by Mergify
parent 2c4b89d1df
commit 412a3836bd
4 changed files with 507 additions and 0 deletions

View File

@@ -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) => `
<button class="btn btn-sm ${cls}" style="display:inline-flex;align-items:center;gap:5px;font-weight:600;padding:6px 14px;">
${frappe.utils.icon(icon_path, "sm", "", "", "", "", icon_color)}
${label}
</button>`;
const icons = {
play: { d: '<polygon points="5 3 19 12 5 21 5 3"/>', fill: "currentColor", stroke: "none" },
pause: {
d: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>',
fill: "currentColor",
stroke: "none",
},
check: { d: '<polyline points="20 6 9 17 4 12"/>', 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(`
<div class="job-card-dashboard-widget"
style="border:1px solid var(--border-color);border-radius:var(--border-radius-lg,8px);
background:var(--card-bg,#fff);padding:16px 20px;margin-bottom:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
<div>
<div style="font-size:10px;color:var(--text-muted);font-weight:600;
text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px;">
${__("Elapsed Time")}
</div>
<div style="display:flex;align-items:center;gap:8px;">
${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)}
<span class="jcd-stopwatch"
style="font-family:var(--monospace-font,'Courier New',monospace);
font-size:28px;font-weight:700;letter-spacing:2px;color:${timer_color};">
00:00:00
</span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
${buttons_html}
</div>
</div>
</div>`);
// ── 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",

View File

@@ -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):

View File

@@ -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"

View File

@@ -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)