From 3907d93f9fe6d0b8667f92d52326729634ca381a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH 1/4] fix(job_card): reject a completion split that cannot add up (#57687) * fix(job_card): reject a completion split that cannot add up The completion dialogs silently dropped a recalculation whose result went negative, so entering a pending qty larger than what is left of the qty to manufacture kept the contradiction (3 to manufacture, 3 completed, 2 pending) and the job card only failed much later, on submission. Keep the split consistent while it is entered: reset the pending qty when the qty to manufacture changes, and refuse a completed, pending or process loss qty that leaves the others negative. complete_job_card validates the same rule, so the shop floor and the API cannot store a split that will never submit. Also name the three parts in the submission error instead of calling their sum the Total Completed Qty, which read as a contradiction of the field itself. * test(job_card): cover the completion qty split guard (cherry picked from commit 7bffd844828475d60562161d7e91640a13501d7c) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/job_card/test_job_card.py # erpnext/public/js/shop_floor/shop_floor.js --- .../doctype/job_card/job_card.js | 44 +- .../doctype/job_card/job_card.py | 42 +- .../doctype/job_card/test_job_card.py | 91 + erpnext/public/js/shop_floor/shop_floor.js | 1747 +++++++++++++++++ 4 files changed, 1919 insertions(+), 5 deletions(-) create mode 100644 erpnext/public/js/shop_floor/shop_floor.js diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 0a4026672cf..32ab1f290a4 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -250,6 +250,7 @@ frappe.ui.form.on("Job Card", { change() { const dialog = frm.job_completion_dialog; dialog.set_value("completed_qty", dialog.get_value("for_quantity")); + dialog.set_value("pending_qty", 0); dialog.set_value("process_loss_qty", 0); }, }, @@ -261,8 +262,21 @@ frappe.ui.form.on("Job Card", { default: pending_qty, change() { const dialog = frm.job_completion_dialog; - const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty"); - if (remaining > 0 && remaining != dialog.get_value("pending_qty")) { + const remaining = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("process_loss_qty"); + + if (remaining < 0) { + const max_completed_qty = + flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty")); + dialog.set_value("completed_qty", max_completed_qty); + frappe.throw( + __("Completed Quantity cannot be greater than {0}", [max_completed_qty]) + ); + } + + if (remaining != dialog.get_value("pending_qty")) { dialog.set_value("pending_qty", remaining); } }, @@ -278,7 +292,18 @@ frappe.ui.form.on("Job Card", { dialog.get_value("for_quantity") - dialog.get_value("completed_qty") - dialog.get_value("pending_qty"); - if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) { + + if (process_loss_qty < 0) { + dialog.set_value("pending_qty", 0); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + ]) + ); + } + + if (process_loss_qty != dialog.get_value("process_loss_qty")) { dialog.set_value("process_loss_qty", process_loss_qty); } }, @@ -293,7 +318,18 @@ frappe.ui.form.on("Job Card", { dialog.get_value("for_quantity") - dialog.get_value("completed_qty") - dialog.get_value("process_loss_qty"); - if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) { + + if (remaining < 0) { + dialog.set_value("process_loss_qty", 0); + frappe.throw( + __("Process Loss Quantity cannot be greater than {0}", [ + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + ]) + ); + } + + if (remaining != dialog.get_value("pending_qty")) { dialog.set_value("pending_qty", remaining); } }, diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..7ca2c7fb136 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -895,12 +895,13 @@ class JobCard(Document): ) precision = self.precision("total_completed_qty") - total_completed_qty = flt( + accounted_qty = flt( flt(self.total_completed_qty, precision) + flt(self.process_loss_qty, precision) + flt(self.pending_qty, precision) ) +<<<<<<< HEAD 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")) @@ -911,6 +912,17 @@ class JobCard(Document): bold(flt(total_completed_qty, precision)), qty_to_manufacture, bold(self.for_quantity), +======= + if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision): + frappe.throw( + _( + "Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})." + ).format( + bold(flt(self.total_completed_qty, precision)), + bold(flt(self.process_loss_qty, precision)), + bold(flt(self.pending_qty, precision)), + bold(flt(self.for_quantity, precision)), +>>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) ) ) @@ -1515,6 +1527,7 @@ class JobCard(Document): kwargs = frappe._dict(kwargs) self.validate_complete_job_card_qty(kwargs) + self.set_for_quantity(kwargs) def validate_docstatus(self): if self.docstatus == 2: @@ -1533,9 +1546,36 @@ 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.")) +<<<<<<< HEAD self.pending_qty = flt(kwargs.pending_qty) self.process_loss_qty = flt(kwargs.process_loss_qty) +======= + 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(flt(kwargs.qty, precision)), + bold(flt(kwargs.pending_qty, precision)), + bold(flt(kwargs.process_loss_qty, precision)), + bold(flt(kwargs.for_quantity, precision)), + ) + ) + + def add_completion_time_logs(self, kwargs): +>>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) if kwargs.end_time: self.add_time_logs( to_time=kwargs.end_time, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..27789e93713 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1879,3 +1879,94 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name +<<<<<<< HEAD +======= + + +class TestJobCardLogic(ERPNextTestSuite): + """Field-level validations and pure quantity/capacity helpers, exercised on the + document directly so they don't need a Work Order / BOM (the integration suite does).""" + + def test_processing_a_submitted_or_cancelled_card_is_blocked(self): + submitted = frappe.new_doc("Job Card") + submitted.docstatus = 1 + self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) + + cancelled = frappe.new_doc("Job Card") + cancelled.docstatus = 2 + self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) + + def test_complete_job_card_qty_guards(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) + ) + + def test_completion_qty_split_must_add_up(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + + # 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) + ) + + self.assertRaises( + frappe.ValidationError, + jc.validate_complete_job_card_qty, + frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), + ) + + def test_completed_qty_must_reconcile_with_for_quantity(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.process_loss_qty = 0 + jc.pending_qty = 0 + # 6 + 0 + 0 != 10 -> throws + self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) + # completed + loss + pending == for_quantity -> passes + jc.pending_qty = 4 + jc.validate_completed_qty_matches_for_quantity() + + def test_set_process_loss(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.pending_qty = 1 + jc.set_process_loss() + self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 + + # no loss when nothing completed yet + nothing_done = frappe.new_doc("Job Card") + nothing_done.for_quantity = 10 + nothing_done.total_completed_qty = 0 + nothing_done.set_process_loss() + self.assertEqual(nothing_done.process_loss_qty, 0) + + def test_capacity_overlap_detection(self): + jc = frappe.new_doc("Job Card") + sequential = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, + ] + overlapping = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, + ] + # sequential logs share one capacity slot; overlapping logs need two + self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) + self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) + # capacity 1 overlaps with any log; capacity 2 only when both slots are taken + self.assertTrue(jc.has_overlap(1, sequential)) + self.assertFalse(jc.has_overlap(2, sequential)) + self.assertTrue(jc.has_overlap(2, overlapping)) +>>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js new file mode 100644 index 00000000000..6e57b77ed7a --- /dev/null +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -0,0 +1,1747 @@ +// Shop Floor — an immersive, keyboard-first operator/manager interface. +// +// Two experiences share one app shell (see get_shop_floor_context on the server): +// • manager — a paginated board of work orders bucketed Pending / In Progress and Completed. +// Drilling into a work order opens its job cards in the operator pane. +// • operator — a focused workstation/work-order view to start, pause, complete and submit jobs. +// +// The whole surface is driveable from the keyboard (press ? for the cheat sheet) so an operator +// at a terminal never needs the mouse. + +// Job Card status → indicator colour, mirrored from workstation.get_status_color so the manager +// board can paint per-operation chips without a round-trip. +const JC_STATUS_COLORS = { + Completed: "green", + Submitted: "blue", + "Work In Progress": "orange", + "Material Transferred": "yellow", + "On Hold": "red", + Open: "gray", + "Not Started": "gray", +}; + +const MANAGER_BUCKETS = [ + { key: "open", label: __("Pending / In Progress"), dot: "orange" }, + { key: "completed", label: __("Completed"), dot: "green" }, +]; + +const PAGE_LENGTH = 20; + +class ShopFloor { + constructor({ wrapper }, page) { + this.wrapper = $(wrapper); + this.page = page; + this.timer_intervals = {}; + this.capacity = 1; + this.mode = null; + // Remembers each Materials panel's open/closed state (keyed by job card) so it + // survives re-renders — otherwise a reload right after a click resets the panel. + this.materials_open = {}; + // Same idea for the per-operation Work Instructions panel. + this.instructions_open = {}; + + // View state. + this.view = "operator"; // overwritten once context loads + this.active_bucket = "open"; + this.with_job_cards_only = true; // board default: hide WOs that have no job cards + this.buckets = {}; // key -> { rows, total, start, loaded } + this.selected_wo = null; + this.focus_index = -1; + this.op_state = { workstation: null, work_order: null }; + + this.make(); + this.bind_realtime(); + this.bind_lifecycle(); + this.init(); + } + + init() { + frappe.call("erpnext.manufacturing.page.shop_floor.shop_floor.get_shop_floor_context").then((r) => { + const ctx = r.message || {}; + this.view = ctx.role_view === "manager" ? "manager" : "operator"; + this.can_manage = !!ctx.can_manage; + this.user_employee = ctx.user_employee || null; + this.render_shell_controls(); + this.render_view(); + this.bind_keys(); + this.initialized = true; + this.apply_route_options(); + }); + } + + // ── App shell ──────────────────────────────────────────────────────────── + make() { + this.wrapper.append(` + ${this.styles()} +
+
+
+
+
+ + + + + +
+
+
+
+
+
+
+
+ `); + + this.app = this.wrapper.find(".sf-app"); + this.brand_icon = `${__(
+			`; + this.topbar_left = this.wrapper.find(".sf-topbar-left"); + this.topbar_center = this.wrapper.find(".sf-topbar-center"); + this.body = this.wrapper.find(".sf-body"); + this.board_container = this.wrapper.find(".sf-board"); + this.detail_container = this.wrapper.find(".sf-detail"); + this.op_container = this.wrapper.find(".sf-operator"); + + this.wrapper.find(".sf-btn-home").on("click", () => (window.location.href = "/app")); + this.wrapper.find(".sf-btn-refresh").on("click", () => this.refresh()); + this.wrapper.find(".sf-btn-scan").on("click", () => this.open_scanner()); + this.wrapper.find(".sf-btn-help").on("click", () => this.show_help()); + this.wrapper.find(".sf-btn-theme").on("click", () => this.toggle_theme()); + this.update_theme_button(); + } + + // Kiosk-friendly light/dark switch: flips the standard desk theme and persists it on the + // User (same as the Ctrl+Shift+G switcher), so the choice survives reloads and follows the + // operator's login on any device. + toggle_theme() { + const next = frappe.ui.get_current_theme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme-mode", next); + frappe.ui.set_theme(next); + frappe.xcall("frappe.core.doctype.user.user.switch_theme", { + theme: next.charAt(0).toUpperCase() + next.slice(1), + }); + this.update_theme_button(); + } + + update_theme_button() { + const dark = frappe.ui.get_current_theme() === "dark"; + this.wrapper + .find(".sf-btn-theme") + .html(dark ? "☀" : "☾") + .attr("title", dark ? __("Switch to Light Theme") : __("Switch to Dark Theme")); + } + + render_shell_controls() { + this.topbar_left.empty(); + this.topbar_center.empty(); + + // View toggle — only managers can flip between the board and a bare operator view. + const toggle = this.can_manage + ? `
+ + +
` + : ""; + + if (this.view === "manager") { + this.topbar_left.html(` + ${this.brand_icon}${__("Shop Floor")} + ${toggle} +
+ ${MANAGER_BUCKETS.map( + (b) => `` + ).join("")} +
+ `); + this.topbar_center.html(` + + + `); + + this.topbar_left.find(".sf-tab").on("click", (e) => { + this.switch_bucket($(e.currentTarget).attr("data-bucket")); + }); + let timer = null; + this.topbar_center.find(".sf-search-input").on("input", (e) => { + const val = e.target.value; + clearTimeout(timer); + timer = setTimeout(() => this.search_work_orders(val), 300); + }); + this.topbar_center.find(".sf-jc-toggle").on("change", (e) => { + this.toggle_job_cards_only(e.target.checked); + }); + } else { + this.topbar_left.html( + `${this.brand_icon}${__("Shop Floor")}${toggle}` + ); + this.build_operator_filters(); + } + + this.topbar_left.find(".sf-view-btn").on("click", (e) => { + this.set_view($(e.currentTarget).attr("data-view")); + }); + } + + build_operator_filters() { + this.topbar_center.html('
'); + const $filters = this.topbar_center.find(".sf-filters"); + + this.workstation_filter = frappe.ui.form.make_control({ + df: { + fieldtype: "Link", + options: "Workstation", + fieldname: "workstation", + placeholder: __("Machine"), + onchange: () => this.load_operator(), + }, + parent: $filters, + render_input: true, + }); + this.workstation_filter.$wrapper.addClass("sf-filter-control"); + + this.work_order_filter = frappe.ui.form.make_control({ + df: { + fieldtype: "Link", + options: "Work Order", + fieldname: "work_order", + placeholder: __("Work Order"), + onchange: () => this.load_operator(), + }, + parent: $filters, + render_input: true, + }); + this.work_order_filter.$wrapper.addClass("sf-filter-control"); + } + + set_view(view) { + if (!view || view === this.view) return; + this.view = view; + this.selected_wo = null; + this.focus_index = -1; + this.render_shell_controls(); + this.render_view(); + } + + render_view() { + const manager = this.view === "manager"; + this.board_container.toggle(manager); + this.detail_container.toggle(manager && !!this.selected_wo); + this.op_container.toggle(!manager); + this.body.toggleClass("detail-open", manager && !!this.selected_wo); + + if (manager) { + this.load_bucket(this.active_bucket); + } else { + this.load_operator(); + } + } + + // ── Manager board ──────────────────────────────────────────────────────── + switch_bucket(bucket) { + if (!bucket || bucket === this.active_bucket) return; + this.active_bucket = bucket; + this.selected_wo = null; + this.focus_index = -1; + this.topbar_left.find(".sf-tab").removeClass("active"); + this.topbar_left.find(`.sf-tab[data-bucket="${bucket}"]`).addClass("active"); + this.detail_container.hide(); + this.body.removeClass("detail-open"); + this.load_bucket(bucket); + } + + search_work_orders(term) { + this.search_term = term; + // Re-query every bucket from scratch on the next visit; reload the active one now. + this.buckets = {}; + this.load_bucket(this.active_bucket); + } + + toggle_job_cards_only(checked) { + this.with_job_cards_only = !!checked; + // Filter changes every bucket's contents + counts; drop caches and clear stale counts. + this.buckets = {}; + this.topbar_left.find(".sf-tab-count").text(""); + this.load_bucket(this.active_bucket); + } + + load_bucket(bucket, append = false) { + const state = this.buckets[bucket] || { rows: [], total: 0, start: 0, loaded: false }; + const start = append ? state.start : 0; + + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_work_orders", + args: { + status_group: bucket, + start: start, + page_length: PAGE_LENGTH, + search: this.search_term || null, + with_job_cards_only: this.with_job_cards_only ? 1 : 0, + }, + callback: (r) => { + const data = r.message || {}; + const rows = data.work_orders || []; + this.buckets[bucket] = { + rows: append ? state.rows.concat(rows) : rows, + total: cint(data.total), + start: start + rows.length, + loaded: true, + }; + this.update_tab_count(bucket); + if (bucket === this.active_bucket) this.render_board(); + }, + }); + } + + update_tab_count(bucket) { + const state = this.buckets[bucket]; + if (!state) return; + this.topbar_left.find(`[data-bucket-count="${bucket}"]`).text(state.total ? state.total : ""); + } + + render_board() { + const state = this.buckets[this.active_bucket] || { rows: [], total: 0 }; + this.focus_index = -1; + + if (!state.rows.length) { + this.board_container.html(`
${__("No work orders here.")}
`); + return; + } + + const cards = state.rows.map((wo) => this.work_order_card(wo)).join(""); + const more = + state.rows.length < state.total + ? `` + : `
${__("Showing all {0}", [state.total])}
`; + + this.board_container.html( + `
${cards}
${more}
` + ); + + this.board_container.find(".sf-wo-card").on("click", (e) => { + this.open_wo($(e.currentTarget).attr("data-name")); + }); + this.board_container + .find(".sf-load-more") + .on("click", () => this.load_bucket(this.active_bucket, true)); + } + + work_order_card(wo) { + const item = wo.item_name || wo.production_item; + + // Hero image = the current operation's workstation. No item-image fallback — when the + // workstation has no image uploaded we show its initials, never the product image. + const image = wo.workstation_image + ? `` + : `${frappe.get_abbr(wo.workstation_name || item, 2)}`; + + const workstation_line = wo.workstation_name + ? `
🏭 ${frappe.utils.escape_html( + wo.workstation_name + )}${wo.current_operation ? ` · ${frappe.utils.escape_html(wo.current_operation)}` : ""}
` + : ""; + + // Operations bar: green segment (done) + orange segment (in progress); grey track = pending. + const done_pct = Math.min(cint(wo.per_operations), 100); + const wip_pct = Math.min(cint(wo.per_in_progress), 100 - done_pct); + + return ` +
+
+
${image}
+
+
${frappe.utils.escape_html(item)}
+ ${workstation_line} +
+ + ${wo.name} +
+
+
+
+
+ ${__("Operations")} + ${cint(wo.completed_operations)} / ${cint(wo.total_operations)} +
+
+
+
+
+
+
+ `; + } + + open_wo(name) { + if (!name) return; + this.selected_wo = name; + this.op_state = { workstation: null, work_order: name }; + this.detail_container.show(); + this.body.addClass("detail-open"); + this.board_container + .find(".sf-wo-card") + .removeClass("sf-selected") + .filter(`[data-name="${name}"]`) + .addClass("sf-selected"); + // The detail pane reuses the operator rendering for a single work order. + this.detail_container.html(` +
+ + ${frappe.utils.escape_html(name)} + ${__("Open")} +
+
+ `); + this.detail_container.find(".sf-detail-back").on("click", () => this.close_wo()); + this.op_container_target = this.detail_container.find(".sf-detail-body"); + this.load_operator_data(this.op_container_target, { work_order: name }); + } + + close_wo() { + this.selected_wo = null; + this.op_container_target = null; + this.detail_container.hide().empty(); + this.body.removeClass("detail-open"); + this.board_container.find(".sf-wo-card").removeClass("sf-selected"); + } + + // ── Operator pane ────────────────────────────────────────────────────────── + // Resolves the container the operator content renders into: the standalone operator + // view, or the manager's drill-down detail pane. + current_op_container() { + return this.view === "manager" ? this.op_container_target : this.op_container; + } + + load_operator() { + const workstation = this.workstation_filter ? this.workstation_filter.get_value() : null; + const work_order = this.work_order_filter ? this.work_order_filter.get_value() : null; + this.op_state = { workstation, work_order }; + + if (!workstation && !work_order) { + this.clear_timers(); + this.op_container.html( + `
${__("Select a machine or work order to begin")}
` + ); + return; + } + this.load_operator_data(this.op_container, { workstation, work_order }); + } + + load_operator_data($container, { workstation, work_order }) { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_data", + args: { + workstation: work_order ? null : workstation, + work_order: work_order || null, + }, + callback: (r) => { + const data = r.message || {}; + this.job_cards = data.job_cards || []; + this.capacity = cint(data.capacity) || 1; + this.mode = data.mode || (work_order ? "work_order" : "workstation"); + this.oee = data.oee || null; + if (data.user_employee) this.user_employee = data.user_employee; + this.today_sessions = data.today_sessions || []; + this.workstation = workstation; + this.work_order = work_order; + this.compute_state(); + this.dedupe_today_sessions(); + this.render_operator($container); + }, + }); + } + + // A job card already shown under Completed Operations shouldn't repeat in + // Today's Sessions — keep it in Completed Operations only. + dedupe_today_sessions() { + const shown = new Set((this.completed || []).map((jc) => jc.name)); + this.today_sessions = (this.today_sessions || []).filter((s) => !shown.has(s.name)); + } + + // Re-fetch whichever operator content is currently on screen (used after every action). + reload() { + if (this.view === "manager" && this.selected_wo) { + this.load_operator_data(this.op_container_target, { work_order: this.selected_wo }); + // Keep the board chips fresh too. + this.buckets = {}; + this.load_bucket(this.active_bucket); + } else if (this.view === "manager") { + this.load_bucket(this.active_bucket); + } else { + this.load_operator(); + } + } + + refresh() { + if (this.view === "manager") { + this.buckets = {}; + } + this.reload(); + } + + compute_state() { + this.active_jobs = []; + this.queue = []; + this.pending_submission = []; + this.completed = []; + // Submitted but the finished goods aren't booked yet (status "To Manufacture") — its own + // actionable section, kept out of Completed Operations / Today's Sessions. + this.to_manufacture = []; + + for (const jc of this.job_cards) { + // Same materials-ready rule as job_card.js make_dashboard. + jc._materials_ready = !!( + jc.skip_material_transfer || + flt(jc.transferred_qty) >= flt(jc.for_quantity) + flt(jc.process_loss_qty) || + !jc.finished_good + ); + + // Submitted JCs are historical from the Shop Floor's POV — only appear here in work_order + // mode (and, for "To Manufacture", in workstation mode too — see _fetch_job_cards). + if (jc.docstatus === 1) { + if (jc.status === "To Manufacture") { + this.to_manufacture.push(jc); + } else { + this.completed.push(jc); + } + continue; + } + + const last_log = + jc.time_logs && jc.time_logs.length ? jc.time_logs[jc.time_logs.length - 1] : null; + const is_running = last_log && !last_log.to_time && !jc.is_paused; + const is_paused = jc.is_paused; + + if (is_running || is_paused) { + this.active_jobs.push(jc); + } else if (jc.status === "Completed") { + // All qty accounted for but still draft — waiting on Submit. + this.pending_submission.push(jc); + } else { + this.queue.push(jc); + } + } + + // Slot rules — all active jobs are always shown; the grid (col-md-6) wraps them 2 per row. + // workstation mode: capacity-many slots, expanded to fit every active job (+ empty placeholders). + // work_order mode: one slot per active job (no empty placeholders). + let slot_count; + if (this.mode === "work_order") { + slot_count = this.active_jobs.length; + } else { + slot_count = Math.max(this.capacity, this.active_jobs.length, 1); + } + + this.slots = []; + for (let i = 0; i < slot_count; i++) { + this.slots.push(this.active_jobs[i] || null); + } + + // Auto-pick: when nothing is running, surface the next queue item in the slot. + if (this.active_jobs.length === 0 && this.queue.length > 0) { + const next_up = this.queue.shift(); + next_up._is_next_up = true; + this.slots[0] = next_up; + } + + this.summary = { + active_count: this.active_jobs.length, + // "To Manufacture" (submitted, qty done, but the Manufacture Stock Entry is still pending) + // isn't actually finished — count it as Pending, not Completed. + queue_count: this.queue.length + this.to_manufacture.length, + completed_count: this.completed.length + this.pending_submission.length, + capacity: this.capacity, + }; + } + + render_operator($container) { + this.clear_timers(); + $container.empty(); + + const html = frappe.render_template("shop_floor_template", { + workstation: this.workstation, + work_order: this.work_order, + mode: this.mode, + slots: this.slots, + active_jobs: this.active_jobs, + queue: this.queue, + pending_submission: this.pending_submission, + to_manufacture: this.to_manufacture, + completed: this.completed, + today_sessions: this.today_sessions || [], + summary: this.summary, + oee: this.oee, + }); + $container.html(html); + + // Restore each Materials panel to its remembered open/closed state. + $container.find(".mes-materials-inline").each((i, el) => { + const $el = $(el); + const name = $el.attr("data-job-card"); + if (!name) return; + if (name in this.materials_open) { + $el.toggleClass("is-open", this.materials_open[name]); + } else { + this.materials_open[name] = $el.hasClass("is-open"); + } + }); + + // Restore each Work Instructions panel to its remembered open/closed state. + $container.find(".mes-instructions-inline").each((i, el) => { + const $el = $(el); + const name = $el.attr("data-job-card"); + if (name && name in this.instructions_open) { + $el.toggleClass("is-open", this.instructions_open[name]); + } + }); + + this.bind_events($container); + + for (const jc of this.active_jobs) { + if (jc.is_paused) { + this.render_timer(jc.name, this.elapsed_seconds(jc), $container); + } else { + this.start_timer_for(jc, $container); + } + } + } + + clear_timers() { + for (const id of Object.values(this.timer_intervals)) { + clearInterval(id); + } + this.timer_intervals = {}; + } + + bind_events($container) { + const me = this; + + $container.find(".mes-materials-summary").on("click", function (e) { + if ($(e.target).closest(".mes-btn-transfer").length) return; + const $inline = $(this).closest(".mes-materials-inline"); + const open = !$inline.hasClass("is-open"); + $inline.toggleClass("is-open", open); + const name = $inline.attr("data-job-card"); + if (name) me.materials_open[name] = open; + }); + + $container.find(".mes-instructions-summary").on("click", function () { + const $inline = $(this).closest(".mes-instructions-inline"); + const open = !$inline.hasClass("is-open"); + $inline.toggleClass("is-open", open); + const name = $inline.attr("data-job-card"); + if (name) me.instructions_open[name] = open; + }); + + // Clicking a "QC Required" / "QC Available" pill runs the inline check ahead of End Session. + $container.find(".mes-qc-pill").on("click", function () { + const name = $(this).attr("data-job-card"); + const jc = (me.active_jobs || []).find((j) => j.name === name); + if (jc) me.run_quality_check(jc, () => me.reload()); + }); + + $container.find(".mes-btn-start").on("click", function () { + me.start_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-pause").on("click", function () { + me.pause_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-resume").on("click", function () { + me.resume_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-end-session").on("click", function () { + me.end_session($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-submit").on("click", function () { + me.submit_job_card($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-make-entry").on("click", function () { + me.make_manufacture_entry($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-transfer").on("click", function (e) { + e.preventDefault(); + me.transfer_materials($(this).attr("data-job-card")); + }); + } + + // ── Operator actions (unchanged behaviour, reload() instead of load()) ───── + start_job(job_card) { + const me = this; + if (this.mode === "workstation" && this.active_jobs.length >= this.capacity) { + frappe.msgprint({ + title: __("Capacity Reached"), + message: __( + "This machine can run at most {0} job(s) in parallel. Pause or complete a running job before starting another.", + [this.capacity] + ), + indicator: "orange", + }); + return; + } + + const default_employee = this.user_employee; + const dialog = new frappe.ui.Dialog({ + title: __("Start Job"), + fields: [ + { + label: __("Start Time"), + fieldname: "start_time", + fieldtype: "Datetime", + default: frappe.datetime.now_datetime(), + }, + { fieldtype: "Section Break" }, + { + label: __("Employees"), + fieldname: "employees", + fieldtype: "Table", + data: default_employee ? [{ employee: default_employee }] : [], + fields: [ + { + label: __("Employee"), + fieldname: "employee", + fieldtype: "Link", + options: "Employee", + in_list_view: 1, + }, + ], + }, + ], + primary_action_label: __("Start"), + primary_action: (values) => { + dialog.hide(); + me.update_job_card(job_card, "start_timer", { + start_time: values.start_time, + employees: values.employees || [], + }); + }, + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + // Make a dialog fully keyboard-operable: Enter triggers the primary action, so an operator + // never has to reach for the mouse. Enter is left alone inside multi-line fields and while an + // autocomplete (Link/Select) dropdown is open, so it can still pick a value. + bind_enter_submit(dialog) { + dialog.$wrapper.on("keydown.sfenter", (e) => { + if (e.key !== "Enter" || e.shiftKey) return; + if ($(e.target).is("textarea")) return; + if ($(".awesomplete > ul:not([hidden])").length) return; + const $btn = dialog.get_primary_btn(); + if ( + $btn && + $btn.length && + $btn.is(":visible") && + !$btn.hasClass("disabled") && + !$btn.prop("disabled") + ) { + e.preventDefault(); + e.stopPropagation(); + $btn.trigger("click"); + } + }); + } + + pause_job(jc_name) { + this.update_job_card(jc_name, "pause_job", { end_time: frappe.datetime.now_datetime() }); + } + + resume_job(jc_name) { + this.update_job_card(jc_name, "resume_job", { start_time: frappe.datetime.now_datetime() }); + } + + end_session(jc_name) { + const me = this; + const jc = this.active_jobs.find((j) => j.name === jc_name); + if (!jc) return; + + let pending = flt(jc.for_quantity) - flt(jc.total_completed_qty); + if (flt(jc.pending_qty) > 0) { + pending = flt(jc.pending_qty); + } + + const fields = [ + { + fieldtype: "Float", + label: __("Qty to Manufacture"), + fieldname: "for_quantity", + reqd: 1, + default: pending, + change() { + const d = me.session_dialog; + d.set_value("completed_qty", d.get_value("for_quantity")); + d.set_value("pending_qty", 0); + d.set_value("process_loss_qty", 0); + }, + }, + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "completed_qty", + reqd: 1, + default: pending, + change() { + const d = me.session_dialog; + const remaining = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("process_loss_qty")); + + if (remaining < 0) { + const max_completed_qty = + flt(d.get_value("for_quantity")) - flt(d.get_value("process_loss_qty")); + d.set_value("completed_qty", max_completed_qty); + frappe.throw( + __("Completed Quantity cannot be greater than {0}", [max_completed_qty]) + ); + } + + if (remaining !== flt(d.get_value("pending_qty"))) { + d.set_value("pending_qty", remaining); + } + }, + }, + { + fieldtype: "Float", + label: __("Pending Quantity"), + fieldname: "pending_qty", + default: 0.0, + change() { + const d = me.session_dialog; + const pl = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("pending_qty")); + + if (pl < 0) { + d.set_value("pending_qty", 0); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")), + ]) + ); + } + + if (pl !== flt(d.get_value("process_loss_qty"))) { + d.set_value("process_loss_qty", pl); + } + }, + }, + { + fieldtype: "Float", + label: __("Process Loss Quantity"), + fieldname: "process_loss_qty", + default: 0.0, + change() { + const d = me.session_dialog; + const remaining = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("process_loss_qty")); + + if (remaining < 0) { + d.set_value("process_loss_qty", 0); + frappe.throw( + __("Process Loss Quantity cannot be greater than {0}", [ + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")), + ]) + ); + } + + if (remaining !== flt(d.get_value("pending_qty"))) { + d.set_value("pending_qty", remaining); + } + }, + }, + { fieldtype: "Section Break" }, + { + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }, + ]; + + const get_payload = () => { + const data = me.session_dialog.get_values(); + if (!data) return null; + if (flt(data.completed_qty) <= 0) { + frappe.throw(__("Completed Quantity should be greater than 0")); + } + return { + job_card: jc.name, + qty: flt(data.completed_qty), + for_quantity: flt(data.for_quantity), + pending_qty: flt(data.pending_qty), + process_loss_qty: flt(data.process_loss_qty), + end_time: data.end_time, + }; + }; + + const save_and_continue = () => { + const args = get_payload(); + if (!args) return; + me.session_dialog.hide(); + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.save_and_continue", + args: args, + freeze: true, + freeze_message: __("Saving job card..."), + callback: () => me.reload(), + }); + }; + + const finalize_submit = (args) => { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.complete_and_submit", + args: args, + freeze: true, + freeze_message: __("Submitting job card..."), + callback: (r) => { + me.reload(); + if (r.message && r.message.finished_good) { + me.prompt_manufacture_entry(jc.name); + } + }, + }); + }; + + const submit_session = () => { + const args = get_payload(); + if (!args) return; + me.session_dialog.hide(); + // Guided QC gate: a job card that requires inspection must pass an inline Quality Check + // before it is submitted (mirrors Job Card.validate_inspection on the server). Once the + // inspection is recorded, finalize the session submit. + if (jc.qc && jc.qc.required && jc.qc.status !== "Accepted") { + me.run_quality_check(jc, () => finalize_submit(args)); + } else { + finalize_submit(args); + } + }; + + me.session_dialog = new frappe.ui.Dialog({ + title: __("End Session"), + fields: fields, + primary_action_label: __("Submit"), + primary_action: submit_session, + secondary_action_label: __("Save & Continue"), + secondary_action: save_and_continue, + }); + me.session_dialog.show(); + me.bind_enter_submit(me.session_dialog); + } + + // ── Inline Quality Check ───────────────────────────────────────────────────── + // Fetch the operation's Quality Inspection template and open a guided pass/fail checklist. + // `on_pass` runs once the inspection has been recorded (and is not rejected). + run_quality_check(jc, on_pass) { + const me = this; + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_quality_inspection_checklist", + args: { job_card: jc.name }, + freeze: true, + freeze_message: __("Loading quality checklist..."), + callback: (r) => { + const info = r.message || {}; + if (!info.template || !(info.parameters || []).length) { + // Inspection is required but the operation has no template/parameters to fill — + // there is nothing to capture inline. Point the user at the configuration. + frappe.msgprint({ + title: __("Quality Inspection Template Missing"), + indicator: "orange", + message: __( + "This operation requires a Quality Inspection but no template with parameters is configured. Set a Quality Inspection Template on Operation {0} to inspect from the Shop Floor.", + [jc.operation || ""] + ), + }); + return; + } + me.show_qc_dialog(jc, info, on_pass); + }, + }); + } + + show_qc_dialog(jc, info, on_pass) { + const me = this; + const params = info.parameters || []; + // Per-row operator input, keyed by row index (avoids escaping issues with parameter names). + const state = {}; // idx -> "Accepted" | "Rejected" + + const rows = params + .map((p, i) => { + const spec = frappe.utils.escape_html(p.specification); + let criteria = ""; + if (p.numeric) { + const lo = p.min_value !== null && p.min_value !== undefined ? p.min_value : "−∞"; + const hi = p.max_value !== null && p.max_value !== undefined ? p.max_value : "∞"; + criteria = __("Acceptable range: {0} to {1}", [lo, hi]); + } else if (p.value) { + criteria = __("Expected: {0}", [frappe.utils.escape_html(p.value)]); + } + const control = p.numeric + ? `` + : ` + + + `; + return `
+
+
${spec}
+ ${criteria ? `
${criteria}
` : ""} +
+
${control}
+
`; + }) + .join(""); + + const dialog = new frappe.ui.Dialog({ + title: __("Quality Check"), + size: "large", + fields: [ + { + fieldtype: "HTML", + options: `
${__( + "Inspect {0} for job card {1}", + [frappe.utils.escape_html(info.item_code || ""), frappe.utils.escape_html(jc.name)] + )}
${rows}
`, + }, + ], + primary_action_label: __("Submit Inspection"), + primary_action: () => { + const readings = []; + let missing = false; + params.forEach((p, i) => { + if (p.numeric) { + const val = dialog.$wrapper.find(`.mes-qc-reading[data-idx="${i}"]`).val(); + if (val === "" || val === undefined || val === null) missing = true; + readings.push({ specification: p.specification, reading_value: val }); + } else { + if (!state[i]) missing = true; + readings.push({ + specification: p.specification, + status: state[i], + reading_value: "", + }); + } + }); + if (missing) { + frappe.msgprint(__("Please complete every check before submitting the inspection.")); + return; + } + dialog.hide(); + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_quality_inspection", + args: { job_card: jc.name, readings: JSON.stringify(readings) }, + freeze: true, + freeze_message: __("Recording inspection..."), + callback: (r) => { + const res = r.message || {}; + if (res.status === "Rejected") { + // Don't auto-proceed on a rejected inspection — the server gate may block the + // submit anyway (per Stock Settings), and the operator should decide next steps. + frappe.msgprint({ + title: __("Inspection Rejected"), + indicator: "red", + message: __( + "Quality Inspection {0} is Rejected. Resolve the issue or follow your rejection process before submitting the job card.", + [res.name || ""] + ), + }); + me.reload(); + return; + } + if (on_pass) on_pass(); + }, + }); + }, + }); + + dialog.show(); + // Pass/Fail toggles for qualitative parameters. + dialog.$wrapper.find(".mes-qc-passfail button").on("click", function () { + const $btn = $(this); + const $grp = $btn.closest(".mes-qc-passfail"); + $grp.find("button").removeClass("active"); + $btn.addClass("active"); + state[$grp.attr("data-idx")] = $btn.attr("data-val"); + }); + } + + prompt_manufacture_entry(jc_name) { + const me = this; + const dialog = new frappe.ui.Dialog({ + title: __("Job Card Submitted"), + fields: [ + { + fieldtype: "HTML", + options: ` +
+
+ ${__("Job card {0} has been submitted.", [frappe.utils.escape_html(jc_name)])} +
+
+ ${__("Create a Manufacture stock entry for the finished goods?")} +
+
+ `, + }, + ], + primary_action_label: __("Make Manufacture Entry"), + primary_action: () => { + dialog.hide(); + me.make_manufacture_entry(jc_name); + }, + secondary_action_label: __("Skip"), + secondary_action: () => dialog.hide(), + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + submit_job_card(jc_name) { + const me = this; + frappe.confirm(__("Submit job card {0}? This finalizes the job card.", [jc_name]), () => { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_job_card", + args: { job_card: jc_name }, + freeze: true, + freeze_message: __("Submitting job card..."), + callback: () => me.reload(), + }); + }); + } + + make_manufacture_entry(jc_name) { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.make_manufacture_stock_entry", + args: { job_card: jc_name }, + freeze: true, + freeze_message: __("Preparing stock entry..."), + callback: (r) => { + if (r.message && r.message.name) { + window.open(`/app/stock-entry/${encodeURIComponent(r.message.name)}`, "_blank"); + } + }, + }); + } + + transfer_materials(jc_name) { + if (!jc_name) return; + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", + args: { source_name: jc_name }, + callback: (r) => { + const doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }, + }); + } + + update_job_card(job_card, method, data, on_success) { + const me = this; + frappe.call({ + method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card", + args: { + job_card: job_card, + method: method, + start_time: data.start_time || "", + employees: data.employees || [], + end_time: data.end_time || "", + qty: data.qty || 0, + for_quantity: data.for_quantity || 0, + pending_qty: data.pending_qty || 0, + process_loss_qty: data.process_loss_qty || 0, + auto_submit: data.auto_submit || 0, + }, + freeze: true, + freeze_message: __("Updating job card..."), + callback: () => { + me.reload(); + if (on_success) on_success(); + }, + }); + } + + // ── Timers ──────────────────────────────────────────────────────────────── + start_timer_for(jc, $container) { + let elapsed = this.elapsed_seconds(jc); + this.render_timer(jc.name, elapsed, $container); + this.timer_intervals[jc.name] = setInterval(() => { + elapsed += 1; + this.render_timer(jc.name, elapsed, $container); + }, 1000); + } + + elapsed_seconds(jc) { + let total = 0; + for (const log of jc.time_logs || []) { + if (log.to_time) { + if (log.time_in_mins) { + total += flt(log.time_in_mins, 2) * 60; + } else { + total += moment(log.to_time).diff(log.from_time, "seconds"); + } + } else { + total += moment().diff(log.from_time, "seconds"); + } + } + return total; + } + + render_timer(jc_name, seconds, $container) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds - h * 3600) / 60); + const s = cint(seconds - h * 3600 - m * 60); + const pad = (n) => (n < 10 ? "0" + n : String(n)); + + const scope = $container || this.wrapper; + const timer = scope.find(`.mes-job-timer[data-job-card="${jc_name}"]`); + timer.find(".h").text(pad(h)); + timer.find(".m").text(pad(m)); + timer.find(".s").text(pad(s)); + } + + // ── Realtime + lifecycle ─────────────────────────────────────────────────── + bind_realtime() { + frappe.realtime.on("update_workstation_status", (data) => { + if (data && data.name === this.op_state.workstation) { + this.reload(); + } + }); + } + + bind_lifecycle() { + // Frappe has no on_page_hide hook, so toggle immersive mode + keyboard binding on + // route changes ourselves. + this._route_handler = () => { + const on_page = (frappe.get_route_str() || "").startsWith("shop-floor"); + if (on_page) { + $(document.body).addClass("shop-floor-active"); + this.bind_keys(); + } else { + $(document.body).removeClass("shop-floor-active"); + this.unbind_keys(); + this.clear_timers(); + } + }; + frappe.router.on("change", this._route_handler); + } + + on_show() { + $(document.body).addClass("shop-floor-active"); + this.bind_keys(); + // Cached re-navigation (e.g. the Work Order "Shop Floor" button) lands here with fresh + // route_options; init() handles the very first load before we're initialized. + if (this.initialized) this.apply_route_options(); + } + + // ── Keyboard ──────────────────────────────────────────────────────────────── + bind_keys() { + $(document).off("keydown.shopfloor"); + $(document).on("keydown.shopfloor", (e) => this.handle_key(e)); + } + + unbind_keys() { + $(document).off("keydown.shopfloor"); + } + + is_typing(e) { + const tag = (e.target.tagName || "").toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || e.target.isContentEditable; + } + + handle_key(e) { + // Let dialogs own the keyboard while open. + if ($(".modal:visible").length) return; + + const typing = this.is_typing(e); + + // Escape works even while typing (blur the search / close the detail pane). + if (e.key === "Escape") { + if (typing) { + e.target.blur(); + return; + } + if (this.view === "manager" && this.selected_wo) { + this.close_wo(); + e.preventDefault(); + } + return; + } + + if (typing) return; + + switch (e.key) { + case "?": + this.show_help(); + e.preventDefault(); + return; + case "/": + this.topbar_center.find(".sf-search-input").focus(); + e.preventDefault(); + return; + case "r": + this.refresh(); + e.preventDefault(); + return; + case "b": + this.open_scanner(); + e.preventDefault(); + return; + case "1": + case "2": + if (this.view === "manager" && MANAGER_BUCKETS[cint(e.key) - 1]) { + this.switch_bucket(MANAGER_BUCKETS[cint(e.key) - 1].key); + e.preventDefault(); + } + return; + } + + // View switch chord: "g" then "m"/"o". + if (e.key === "g") { + this._g_pending = true; + setTimeout(() => (this._g_pending = false), 600); + return; + } + if (this._g_pending && (e.key === "m" || e.key === "o")) { + this._g_pending = false; + if (this.can_manage) this.set_view(e.key === "m" ? "manager" : "operator"); + return; + } + + // Navigation. + if (e.key === "ArrowDown" || e.key === "j") { + this.move_focus(1); + e.preventDefault(); + return; + } + if (e.key === "ArrowUp" || e.key === "k") { + this.move_focus(-1); + e.preventDefault(); + return; + } + if (e.key === "Enter") { + this.activate_focus(); + e.preventDefault(); + return; + } + + // Job actions on the focused card — reuse the rendered buttons. + const map = { + s: ".mes-btn-start, .mes-btn-resume", + p: ".mes-btn-pause, .mes-btn-resume", + e: ".mes-btn-end-session", + t: ".mes-btn-transfer", + }; + if (e.key === "S" && e.shiftKey) { + this.click_job_action(".mes-btn-submit"); + e.preventDefault(); + return; + } + if (map[e.key]) { + this.click_job_action(map[e.key]); + e.preventDefault(); + } + } + + // Job actions act on the focused job card (operator view); when the focus is on a board + // work order (manager view with the detail open) they fall back to the detail's active job. + click_job_action(selector) { + const $el = this.focused_el(); + if ($el && $el.attr("data-kind") === "job") { + const $btn = $el.find(selector).filter(":visible").first(); + if ($btn.length) { + $btn.trigger("click"); + return; + } + } + const scope = this.current_op_container(); + if (scope && scope.length) { + const $btn = scope.find(selector).filter(":visible").first(); + if ($btn.length) $btn.trigger("click"); + } + } + + focusables() { + // Manager always navigates the board work orders — even with the detail open, so the + // arrow keys switch work orders. The standalone operator view navigates its job cards. + const scope = this.view === "manager" ? this.board_container : this.current_op_container(); + if (!scope || !scope.length) return $(); + return scope.find("[data-sf-focusable]"); + } + + move_focus(delta) { + const $items = this.focusables(); + if (!$items.length) return; + this.focus_index = Math.max(0, Math.min($items.length - 1, this.focus_index + delta)); + $items.removeClass("sf-focused"); + const $target = $items.eq(this.focus_index); + $target.addClass("sf-focused"); + $target[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + // Browsing work orders with the detail already open → switch the detail to the focused one. + if (this.view === "manager" && this.selected_wo && $target.attr("data-kind") === "wo") { + this.open_wo($target.attr("data-name")); + } + } + + focused_el() { + const $items = this.focusables(); + if (this.focus_index < 0 || this.focus_index >= $items.length) return null; + return $items.eq(this.focus_index); + } + + activate_focus() { + const $el = this.focused_el(); + if (!$el) return; + if ($el.attr("data-kind") === "wo") { + this.open_wo($el.attr("data-name")); + } else { + // First visible primary button drives the job card (Start / Resume / End Session). + const $btn = $el.find(".btn-primary:visible").first(); + if ($btn.length) $btn.trigger("click"); + } + } + + show_help() { + const rows = [ + ["?", __("Show this help")], + ["/", __("Search work orders")], + ["r", __("Refresh")], + ["b", __("Scan job card")], + ["g then m / o", __("Switch Board / Operator view")], + ["1 / 2", __("Switch board tab")], + ["↑ / ↓ or j / k", __("Move selection")], + ["Enter", __("Open work order / run primary action")], + ["Esc", __("Close detail / blur search")], + ["s", __("Start / Resume job")], + ["p", __("Pause / Resume job")], + ["e", __("End session for active job")], + ["t", __("Transfer materials")], + ["Shift + S", __("Submit focused job card")], + ]; + const html = `
${rows + .map((r) => `
${r[0]}${r[1]}
`) + .join("")}
`; + const d = new frappe.ui.Dialog({ + title: __("Keyboard Shortcuts"), + fields: [{ fieldtype: "HTML", options: html }], + }); + d.show(); + } + + // ── Scanner ────────────────────────────────────────────────────────────── + open_scanner() { + const me = this; + const dialog = new frappe.ui.Dialog({ + title: __("Scan Job Card"), + fields: [ + { + label: __("Scan or enter Job Card"), + fieldname: "job_card", + fieldtype: "Data", + options: "Barcode", + }, + ], + primary_action_label: __("Continue"), + primary_action: (values) => { + if (!values.job_card) return; + dialog.hide(); + me.handle_scanned_job_card(values.job_card); + }, + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + handle_scanned_job_card(job_card) { + const me = this; + const jc = (this.job_cards || []).find((j) => j.name === job_card); + if (jc) { + me.route_scanned_action(jc); + return; + } + frappe.db.get_value("Job Card", job_card, ["status", "is_paused", "docstatus"]).then((r) => { + const data = r && r.message; + if (!data || !data.status) { + frappe.msgprint(__("Job Card {0} was not found.", [job_card])); + return; + } + if (cint(data.docstatus) === 1) { + frappe.msgprint(__("Job Card {0} is already submitted.", [job_card])); + } else if (cint(data.is_paused)) { + me.resume_job(job_card); + } else if (data.status === "Work In Progress") { + frappe.msgprint( + __( + "Job Card {0} is already running. Open its machine or work order to pause or complete it.", + [job_card] + ) + ); + } else if (data.status === "Completed") { + me.submit_job_card(job_card); + } else { + me.start_job(job_card); + } + }); + } + + route_scanned_action(jc) { + const me = this; + if (jc.docstatus === 1) { + frappe.msgprint(__("Job Card {0} is already submitted.", [jc.name])); + return; + } + if (jc.status === "Completed") { + me.submit_job_card(jc.name); + return; + } + if (jc.is_paused) { + me.resume_job(jc.name); + return; + } + const last_log = jc.time_logs && jc.time_logs.length ? jc.time_logs[jc.time_logs.length - 1] : null; + const is_running = !!(last_log && !last_log.to_time); + if (is_running) { + me.prompt_running_action(jc); + } else { + me.start_job(jc.name); + } + } + + prompt_running_action(jc) { + const me = this; + const dialog = new frappe.ui.Dialog({ + title: __("Job {0} is running", [jc.name]), + fields: [ + { + fieldtype: "HTML", + options: ` +
+ ${__("{0} is already in progress. Pause it or complete the session.", [ + frappe.utils.escape_html(jc.finished_good || jc.production_item || jc.name), + ])} +
+ `, + }, + ], + primary_action_label: __("Complete"), + primary_action: () => { + dialog.hide(); + me.end_session(jc.name); + }, + secondary_action_label: __("Pause"), + secondary_action: () => { + dialog.hide(); + me.pause_job(jc.name); + }, + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + // ── Route options (e.g. the Work Order "Shop Floor" button) ──────────────── + apply_route_options() { + const opts = frappe.route_options; + if (!opts || (!opts.work_order && !opts.workstation)) { + return; + } + frappe.route_options = null; + + // A specific work order / machine was requested — show it in the operator view. + this.view = "operator"; + this.render_shell_controls(); + this.render_view(); + Promise.all([ + this.work_order_filter.set_value(opts.work_order || ""), + this.workstation_filter.set_value(opts.workstation || ""), + ]).then(() => this.load_operator()); + } + + // ── Styles ────────────────────────────────────────────────────────────────── + styles() { + return ``; + } +} + +frappe.ui.ShopFloor = ShopFloor; From c955f80675af2a48b7a8bce13f71cb63704b5175 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:50:50 +0530 Subject: [PATCH 2/4] chore: resolve conflict --- .../doctype/job_card/job_card.py | 64 +- .../doctype/job_card/test_job_card.py | 105 +- erpnext/public/js/shop_floor/shop_floor.js | 1747 ----------------- 3 files changed, 37 insertions(+), 1879 deletions(-) delete mode 100644 erpnext/public/js/shop_floor/shop_floor.js diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 7ca2c7fb136..ba41a7c67fe 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -901,18 +901,6 @@ class JobCard(Document): + flt(self.pending_qty, precision) ) -<<<<<<< HEAD - 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(flt(total_completed_qty, precision)), - qty_to_manufacture, - bold(self.for_quantity), -======= if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision): frappe.throw( _( @@ -922,7 +910,6 @@ class JobCard(Document): bold(flt(self.process_loss_qty, precision)), bold(flt(self.pending_qty, precision)), bold(flt(self.for_quantity, precision)), ->>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) ) ) @@ -1527,7 +1514,6 @@ class JobCard(Document): kwargs = frappe._dict(kwargs) self.validate_complete_job_card_qty(kwargs) - self.set_for_quantity(kwargs) def validate_docstatus(self): if self.docstatus == 2: @@ -1546,36 +1532,11 @@ 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.")) -<<<<<<< HEAD + self.validate_completion_qty_split(kwargs) + self.pending_qty = flt(kwargs.pending_qty) self.process_loss_qty = flt(kwargs.process_loss_qty) -======= - 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(flt(kwargs.qty, precision)), - bold(flt(kwargs.pending_qty, precision)), - bold(flt(kwargs.process_loss_qty, precision)), - bold(flt(kwargs.for_quantity, precision)), - ) - ) - - def add_completion_time_logs(self, kwargs): ->>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) if kwargs.end_time: self.add_time_logs( to_time=kwargs.end_time, @@ -1601,6 +1562,27 @@ class JobCard(Document): _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)) ) + 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(flt(kwargs.qty, precision)), + bold(flt(kwargs.pending_qty, precision)), + bold(flt(kwargs.process_loss_qty, precision)), + bold(flt(kwargs.for_quantity, precision)), + ) + ) + @frappe.whitelist() def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False): def get_consumed_process_loss(): diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 27789e93713..95e9e8dbfb6 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1817,6 +1817,20 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(s.additional_costs[2].amount, 480) self.assertEqual(s.additional_costs[3].amount, 480) + def test_completion_qty_split_must_add_up(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) + ) + + self.assertRaises( + frappe.ValidationError, + jc.validate_complete_job_card_qty, + frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), + ) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" @@ -1879,94 +1893,3 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name -<<<<<<< HEAD -======= - - -class TestJobCardLogic(ERPNextTestSuite): - """Field-level validations and pure quantity/capacity helpers, exercised on the - document directly so they don't need a Work Order / BOM (the integration suite does).""" - - def test_processing_a_submitted_or_cancelled_card_is_blocked(self): - submitted = frappe.new_doc("Job Card") - submitted.docstatus = 1 - self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) - - cancelled = frappe.new_doc("Job Card") - cancelled.docstatus = 2 - self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) - - def test_complete_job_card_qty_guards(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 5 - jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) - ) - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) - ) - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) - ) - - def test_completion_qty_split_must_add_up(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 5 - - # 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes - jc.validate_complete_job_card_qty( - frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) - ) - - self.assertRaises( - frappe.ValidationError, - jc.validate_complete_job_card_qty, - frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), - ) - - def test_completed_qty_must_reconcile_with_for_quantity(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 10 - jc.total_completed_qty = 6 - jc.process_loss_qty = 0 - jc.pending_qty = 0 - # 6 + 0 + 0 != 10 -> throws - self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) - # completed + loss + pending == for_quantity -> passes - jc.pending_qty = 4 - jc.validate_completed_qty_matches_for_quantity() - - def test_set_process_loss(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 10 - jc.total_completed_qty = 6 - jc.pending_qty = 1 - jc.set_process_loss() - self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 - - # no loss when nothing completed yet - nothing_done = frappe.new_doc("Job Card") - nothing_done.for_quantity = 10 - nothing_done.total_completed_qty = 0 - nothing_done.set_process_loss() - self.assertEqual(nothing_done.process_loss_qty, 0) - - def test_capacity_overlap_detection(self): - jc = frappe.new_doc("Job Card") - sequential = [ - {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, - {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, - ] - overlapping = [ - {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, - {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, - ] - # sequential logs share one capacity slot; overlapping logs need two - self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) - self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) - # capacity 1 overlaps with any log; capacity 2 only when both slots are taken - self.assertTrue(jc.has_overlap(1, sequential)) - self.assertFalse(jc.has_overlap(2, sequential)) - self.assertTrue(jc.has_overlap(2, overlapping)) ->>>>>>> 7bffd84482 (fix(job_card): reject a completion split that cannot add up (#57687)) diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js deleted file mode 100644 index 6e57b77ed7a..00000000000 --- a/erpnext/public/js/shop_floor/shop_floor.js +++ /dev/null @@ -1,1747 +0,0 @@ -// Shop Floor — an immersive, keyboard-first operator/manager interface. -// -// Two experiences share one app shell (see get_shop_floor_context on the server): -// • manager — a paginated board of work orders bucketed Pending / In Progress and Completed. -// Drilling into a work order opens its job cards in the operator pane. -// • operator — a focused workstation/work-order view to start, pause, complete and submit jobs. -// -// The whole surface is driveable from the keyboard (press ? for the cheat sheet) so an operator -// at a terminal never needs the mouse. - -// Job Card status → indicator colour, mirrored from workstation.get_status_color so the manager -// board can paint per-operation chips without a round-trip. -const JC_STATUS_COLORS = { - Completed: "green", - Submitted: "blue", - "Work In Progress": "orange", - "Material Transferred": "yellow", - "On Hold": "red", - Open: "gray", - "Not Started": "gray", -}; - -const MANAGER_BUCKETS = [ - { key: "open", label: __("Pending / In Progress"), dot: "orange" }, - { key: "completed", label: __("Completed"), dot: "green" }, -]; - -const PAGE_LENGTH = 20; - -class ShopFloor { - constructor({ wrapper }, page) { - this.wrapper = $(wrapper); - this.page = page; - this.timer_intervals = {}; - this.capacity = 1; - this.mode = null; - // Remembers each Materials panel's open/closed state (keyed by job card) so it - // survives re-renders — otherwise a reload right after a click resets the panel. - this.materials_open = {}; - // Same idea for the per-operation Work Instructions panel. - this.instructions_open = {}; - - // View state. - this.view = "operator"; // overwritten once context loads - this.active_bucket = "open"; - this.with_job_cards_only = true; // board default: hide WOs that have no job cards - this.buckets = {}; // key -> { rows, total, start, loaded } - this.selected_wo = null; - this.focus_index = -1; - this.op_state = { workstation: null, work_order: null }; - - this.make(); - this.bind_realtime(); - this.bind_lifecycle(); - this.init(); - } - - init() { - frappe.call("erpnext.manufacturing.page.shop_floor.shop_floor.get_shop_floor_context").then((r) => { - const ctx = r.message || {}; - this.view = ctx.role_view === "manager" ? "manager" : "operator"; - this.can_manage = !!ctx.can_manage; - this.user_employee = ctx.user_employee || null; - this.render_shell_controls(); - this.render_view(); - this.bind_keys(); - this.initialized = true; - this.apply_route_options(); - }); - } - - // ── App shell ──────────────────────────────────────────────────────────── - make() { - this.wrapper.append(` - ${this.styles()} -
-
-
-
-
- - - - - -
-
-
-
-
-
-
-
- `); - - this.app = this.wrapper.find(".sf-app"); - this.brand_icon = `${__(
-			`; - this.topbar_left = this.wrapper.find(".sf-topbar-left"); - this.topbar_center = this.wrapper.find(".sf-topbar-center"); - this.body = this.wrapper.find(".sf-body"); - this.board_container = this.wrapper.find(".sf-board"); - this.detail_container = this.wrapper.find(".sf-detail"); - this.op_container = this.wrapper.find(".sf-operator"); - - this.wrapper.find(".sf-btn-home").on("click", () => (window.location.href = "/app")); - this.wrapper.find(".sf-btn-refresh").on("click", () => this.refresh()); - this.wrapper.find(".sf-btn-scan").on("click", () => this.open_scanner()); - this.wrapper.find(".sf-btn-help").on("click", () => this.show_help()); - this.wrapper.find(".sf-btn-theme").on("click", () => this.toggle_theme()); - this.update_theme_button(); - } - - // Kiosk-friendly light/dark switch: flips the standard desk theme and persists it on the - // User (same as the Ctrl+Shift+G switcher), so the choice survives reloads and follows the - // operator's login on any device. - toggle_theme() { - const next = frappe.ui.get_current_theme() === "dark" ? "light" : "dark"; - document.documentElement.setAttribute("data-theme-mode", next); - frappe.ui.set_theme(next); - frappe.xcall("frappe.core.doctype.user.user.switch_theme", { - theme: next.charAt(0).toUpperCase() + next.slice(1), - }); - this.update_theme_button(); - } - - update_theme_button() { - const dark = frappe.ui.get_current_theme() === "dark"; - this.wrapper - .find(".sf-btn-theme") - .html(dark ? "☀" : "☾") - .attr("title", dark ? __("Switch to Light Theme") : __("Switch to Dark Theme")); - } - - render_shell_controls() { - this.topbar_left.empty(); - this.topbar_center.empty(); - - // View toggle — only managers can flip between the board and a bare operator view. - const toggle = this.can_manage - ? `
- - -
` - : ""; - - if (this.view === "manager") { - this.topbar_left.html(` - ${this.brand_icon}${__("Shop Floor")} - ${toggle} -
- ${MANAGER_BUCKETS.map( - (b) => `` - ).join("")} -
- `); - this.topbar_center.html(` - - - `); - - this.topbar_left.find(".sf-tab").on("click", (e) => { - this.switch_bucket($(e.currentTarget).attr("data-bucket")); - }); - let timer = null; - this.topbar_center.find(".sf-search-input").on("input", (e) => { - const val = e.target.value; - clearTimeout(timer); - timer = setTimeout(() => this.search_work_orders(val), 300); - }); - this.topbar_center.find(".sf-jc-toggle").on("change", (e) => { - this.toggle_job_cards_only(e.target.checked); - }); - } else { - this.topbar_left.html( - `${this.brand_icon}${__("Shop Floor")}${toggle}` - ); - this.build_operator_filters(); - } - - this.topbar_left.find(".sf-view-btn").on("click", (e) => { - this.set_view($(e.currentTarget).attr("data-view")); - }); - } - - build_operator_filters() { - this.topbar_center.html('
'); - const $filters = this.topbar_center.find(".sf-filters"); - - this.workstation_filter = frappe.ui.form.make_control({ - df: { - fieldtype: "Link", - options: "Workstation", - fieldname: "workstation", - placeholder: __("Machine"), - onchange: () => this.load_operator(), - }, - parent: $filters, - render_input: true, - }); - this.workstation_filter.$wrapper.addClass("sf-filter-control"); - - this.work_order_filter = frappe.ui.form.make_control({ - df: { - fieldtype: "Link", - options: "Work Order", - fieldname: "work_order", - placeholder: __("Work Order"), - onchange: () => this.load_operator(), - }, - parent: $filters, - render_input: true, - }); - this.work_order_filter.$wrapper.addClass("sf-filter-control"); - } - - set_view(view) { - if (!view || view === this.view) return; - this.view = view; - this.selected_wo = null; - this.focus_index = -1; - this.render_shell_controls(); - this.render_view(); - } - - render_view() { - const manager = this.view === "manager"; - this.board_container.toggle(manager); - this.detail_container.toggle(manager && !!this.selected_wo); - this.op_container.toggle(!manager); - this.body.toggleClass("detail-open", manager && !!this.selected_wo); - - if (manager) { - this.load_bucket(this.active_bucket); - } else { - this.load_operator(); - } - } - - // ── Manager board ──────────────────────────────────────────────────────── - switch_bucket(bucket) { - if (!bucket || bucket === this.active_bucket) return; - this.active_bucket = bucket; - this.selected_wo = null; - this.focus_index = -1; - this.topbar_left.find(".sf-tab").removeClass("active"); - this.topbar_left.find(`.sf-tab[data-bucket="${bucket}"]`).addClass("active"); - this.detail_container.hide(); - this.body.removeClass("detail-open"); - this.load_bucket(bucket); - } - - search_work_orders(term) { - this.search_term = term; - // Re-query every bucket from scratch on the next visit; reload the active one now. - this.buckets = {}; - this.load_bucket(this.active_bucket); - } - - toggle_job_cards_only(checked) { - this.with_job_cards_only = !!checked; - // Filter changes every bucket's contents + counts; drop caches and clear stale counts. - this.buckets = {}; - this.topbar_left.find(".sf-tab-count").text(""); - this.load_bucket(this.active_bucket); - } - - load_bucket(bucket, append = false) { - const state = this.buckets[bucket] || { rows: [], total: 0, start: 0, loaded: false }; - const start = append ? state.start : 0; - - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_work_orders", - args: { - status_group: bucket, - start: start, - page_length: PAGE_LENGTH, - search: this.search_term || null, - with_job_cards_only: this.with_job_cards_only ? 1 : 0, - }, - callback: (r) => { - const data = r.message || {}; - const rows = data.work_orders || []; - this.buckets[bucket] = { - rows: append ? state.rows.concat(rows) : rows, - total: cint(data.total), - start: start + rows.length, - loaded: true, - }; - this.update_tab_count(bucket); - if (bucket === this.active_bucket) this.render_board(); - }, - }); - } - - update_tab_count(bucket) { - const state = this.buckets[bucket]; - if (!state) return; - this.topbar_left.find(`[data-bucket-count="${bucket}"]`).text(state.total ? state.total : ""); - } - - render_board() { - const state = this.buckets[this.active_bucket] || { rows: [], total: 0 }; - this.focus_index = -1; - - if (!state.rows.length) { - this.board_container.html(`
${__("No work orders here.")}
`); - return; - } - - const cards = state.rows.map((wo) => this.work_order_card(wo)).join(""); - const more = - state.rows.length < state.total - ? `` - : `
${__("Showing all {0}", [state.total])}
`; - - this.board_container.html( - `
${cards}
${more}
` - ); - - this.board_container.find(".sf-wo-card").on("click", (e) => { - this.open_wo($(e.currentTarget).attr("data-name")); - }); - this.board_container - .find(".sf-load-more") - .on("click", () => this.load_bucket(this.active_bucket, true)); - } - - work_order_card(wo) { - const item = wo.item_name || wo.production_item; - - // Hero image = the current operation's workstation. No item-image fallback — when the - // workstation has no image uploaded we show its initials, never the product image. - const image = wo.workstation_image - ? `` - : `${frappe.get_abbr(wo.workstation_name || item, 2)}`; - - const workstation_line = wo.workstation_name - ? `
🏭 ${frappe.utils.escape_html( - wo.workstation_name - )}${wo.current_operation ? ` · ${frappe.utils.escape_html(wo.current_operation)}` : ""}
` - : ""; - - // Operations bar: green segment (done) + orange segment (in progress); grey track = pending. - const done_pct = Math.min(cint(wo.per_operations), 100); - const wip_pct = Math.min(cint(wo.per_in_progress), 100 - done_pct); - - return ` -
-
-
${image}
-
-
${frappe.utils.escape_html(item)}
- ${workstation_line} -
- - ${wo.name} -
-
-
-
-
- ${__("Operations")} - ${cint(wo.completed_operations)} / ${cint(wo.total_operations)} -
-
-
-
-
-
-
- `; - } - - open_wo(name) { - if (!name) return; - this.selected_wo = name; - this.op_state = { workstation: null, work_order: name }; - this.detail_container.show(); - this.body.addClass("detail-open"); - this.board_container - .find(".sf-wo-card") - .removeClass("sf-selected") - .filter(`[data-name="${name}"]`) - .addClass("sf-selected"); - // The detail pane reuses the operator rendering for a single work order. - this.detail_container.html(` -
- - ${frappe.utils.escape_html(name)} - ${__("Open")} -
-
- `); - this.detail_container.find(".sf-detail-back").on("click", () => this.close_wo()); - this.op_container_target = this.detail_container.find(".sf-detail-body"); - this.load_operator_data(this.op_container_target, { work_order: name }); - } - - close_wo() { - this.selected_wo = null; - this.op_container_target = null; - this.detail_container.hide().empty(); - this.body.removeClass("detail-open"); - this.board_container.find(".sf-wo-card").removeClass("sf-selected"); - } - - // ── Operator pane ────────────────────────────────────────────────────────── - // Resolves the container the operator content renders into: the standalone operator - // view, or the manager's drill-down detail pane. - current_op_container() { - return this.view === "manager" ? this.op_container_target : this.op_container; - } - - load_operator() { - const workstation = this.workstation_filter ? this.workstation_filter.get_value() : null; - const work_order = this.work_order_filter ? this.work_order_filter.get_value() : null; - this.op_state = { workstation, work_order }; - - if (!workstation && !work_order) { - this.clear_timers(); - this.op_container.html( - `
${__("Select a machine or work order to begin")}
` - ); - return; - } - this.load_operator_data(this.op_container, { workstation, work_order }); - } - - load_operator_data($container, { workstation, work_order }) { - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_data", - args: { - workstation: work_order ? null : workstation, - work_order: work_order || null, - }, - callback: (r) => { - const data = r.message || {}; - this.job_cards = data.job_cards || []; - this.capacity = cint(data.capacity) || 1; - this.mode = data.mode || (work_order ? "work_order" : "workstation"); - this.oee = data.oee || null; - if (data.user_employee) this.user_employee = data.user_employee; - this.today_sessions = data.today_sessions || []; - this.workstation = workstation; - this.work_order = work_order; - this.compute_state(); - this.dedupe_today_sessions(); - this.render_operator($container); - }, - }); - } - - // A job card already shown under Completed Operations shouldn't repeat in - // Today's Sessions — keep it in Completed Operations only. - dedupe_today_sessions() { - const shown = new Set((this.completed || []).map((jc) => jc.name)); - this.today_sessions = (this.today_sessions || []).filter((s) => !shown.has(s.name)); - } - - // Re-fetch whichever operator content is currently on screen (used after every action). - reload() { - if (this.view === "manager" && this.selected_wo) { - this.load_operator_data(this.op_container_target, { work_order: this.selected_wo }); - // Keep the board chips fresh too. - this.buckets = {}; - this.load_bucket(this.active_bucket); - } else if (this.view === "manager") { - this.load_bucket(this.active_bucket); - } else { - this.load_operator(); - } - } - - refresh() { - if (this.view === "manager") { - this.buckets = {}; - } - this.reload(); - } - - compute_state() { - this.active_jobs = []; - this.queue = []; - this.pending_submission = []; - this.completed = []; - // Submitted but the finished goods aren't booked yet (status "To Manufacture") — its own - // actionable section, kept out of Completed Operations / Today's Sessions. - this.to_manufacture = []; - - for (const jc of this.job_cards) { - // Same materials-ready rule as job_card.js make_dashboard. - jc._materials_ready = !!( - jc.skip_material_transfer || - flt(jc.transferred_qty) >= flt(jc.for_quantity) + flt(jc.process_loss_qty) || - !jc.finished_good - ); - - // Submitted JCs are historical from the Shop Floor's POV — only appear here in work_order - // mode (and, for "To Manufacture", in workstation mode too — see _fetch_job_cards). - if (jc.docstatus === 1) { - if (jc.status === "To Manufacture") { - this.to_manufacture.push(jc); - } else { - this.completed.push(jc); - } - continue; - } - - const last_log = - jc.time_logs && jc.time_logs.length ? jc.time_logs[jc.time_logs.length - 1] : null; - const is_running = last_log && !last_log.to_time && !jc.is_paused; - const is_paused = jc.is_paused; - - if (is_running || is_paused) { - this.active_jobs.push(jc); - } else if (jc.status === "Completed") { - // All qty accounted for but still draft — waiting on Submit. - this.pending_submission.push(jc); - } else { - this.queue.push(jc); - } - } - - // Slot rules — all active jobs are always shown; the grid (col-md-6) wraps them 2 per row. - // workstation mode: capacity-many slots, expanded to fit every active job (+ empty placeholders). - // work_order mode: one slot per active job (no empty placeholders). - let slot_count; - if (this.mode === "work_order") { - slot_count = this.active_jobs.length; - } else { - slot_count = Math.max(this.capacity, this.active_jobs.length, 1); - } - - this.slots = []; - for (let i = 0; i < slot_count; i++) { - this.slots.push(this.active_jobs[i] || null); - } - - // Auto-pick: when nothing is running, surface the next queue item in the slot. - if (this.active_jobs.length === 0 && this.queue.length > 0) { - const next_up = this.queue.shift(); - next_up._is_next_up = true; - this.slots[0] = next_up; - } - - this.summary = { - active_count: this.active_jobs.length, - // "To Manufacture" (submitted, qty done, but the Manufacture Stock Entry is still pending) - // isn't actually finished — count it as Pending, not Completed. - queue_count: this.queue.length + this.to_manufacture.length, - completed_count: this.completed.length + this.pending_submission.length, - capacity: this.capacity, - }; - } - - render_operator($container) { - this.clear_timers(); - $container.empty(); - - const html = frappe.render_template("shop_floor_template", { - workstation: this.workstation, - work_order: this.work_order, - mode: this.mode, - slots: this.slots, - active_jobs: this.active_jobs, - queue: this.queue, - pending_submission: this.pending_submission, - to_manufacture: this.to_manufacture, - completed: this.completed, - today_sessions: this.today_sessions || [], - summary: this.summary, - oee: this.oee, - }); - $container.html(html); - - // Restore each Materials panel to its remembered open/closed state. - $container.find(".mes-materials-inline").each((i, el) => { - const $el = $(el); - const name = $el.attr("data-job-card"); - if (!name) return; - if (name in this.materials_open) { - $el.toggleClass("is-open", this.materials_open[name]); - } else { - this.materials_open[name] = $el.hasClass("is-open"); - } - }); - - // Restore each Work Instructions panel to its remembered open/closed state. - $container.find(".mes-instructions-inline").each((i, el) => { - const $el = $(el); - const name = $el.attr("data-job-card"); - if (name && name in this.instructions_open) { - $el.toggleClass("is-open", this.instructions_open[name]); - } - }); - - this.bind_events($container); - - for (const jc of this.active_jobs) { - if (jc.is_paused) { - this.render_timer(jc.name, this.elapsed_seconds(jc), $container); - } else { - this.start_timer_for(jc, $container); - } - } - } - - clear_timers() { - for (const id of Object.values(this.timer_intervals)) { - clearInterval(id); - } - this.timer_intervals = {}; - } - - bind_events($container) { - const me = this; - - $container.find(".mes-materials-summary").on("click", function (e) { - if ($(e.target).closest(".mes-btn-transfer").length) return; - const $inline = $(this).closest(".mes-materials-inline"); - const open = !$inline.hasClass("is-open"); - $inline.toggleClass("is-open", open); - const name = $inline.attr("data-job-card"); - if (name) me.materials_open[name] = open; - }); - - $container.find(".mes-instructions-summary").on("click", function () { - const $inline = $(this).closest(".mes-instructions-inline"); - const open = !$inline.hasClass("is-open"); - $inline.toggleClass("is-open", open); - const name = $inline.attr("data-job-card"); - if (name) me.instructions_open[name] = open; - }); - - // Clicking a "QC Required" / "QC Available" pill runs the inline check ahead of End Session. - $container.find(".mes-qc-pill").on("click", function () { - const name = $(this).attr("data-job-card"); - const jc = (me.active_jobs || []).find((j) => j.name === name); - if (jc) me.run_quality_check(jc, () => me.reload()); - }); - - $container.find(".mes-btn-start").on("click", function () { - me.start_job($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-pause").on("click", function () { - me.pause_job($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-resume").on("click", function () { - me.resume_job($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-end-session").on("click", function () { - me.end_session($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-submit").on("click", function () { - me.submit_job_card($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-make-entry").on("click", function () { - me.make_manufacture_entry($(this).attr("data-job-card")); - }); - $container.find(".mes-btn-transfer").on("click", function (e) { - e.preventDefault(); - me.transfer_materials($(this).attr("data-job-card")); - }); - } - - // ── Operator actions (unchanged behaviour, reload() instead of load()) ───── - start_job(job_card) { - const me = this; - if (this.mode === "workstation" && this.active_jobs.length >= this.capacity) { - frappe.msgprint({ - title: __("Capacity Reached"), - message: __( - "This machine can run at most {0} job(s) in parallel. Pause or complete a running job before starting another.", - [this.capacity] - ), - indicator: "orange", - }); - return; - } - - const default_employee = this.user_employee; - const dialog = new frappe.ui.Dialog({ - title: __("Start Job"), - fields: [ - { - label: __("Start Time"), - fieldname: "start_time", - fieldtype: "Datetime", - default: frappe.datetime.now_datetime(), - }, - { fieldtype: "Section Break" }, - { - label: __("Employees"), - fieldname: "employees", - fieldtype: "Table", - data: default_employee ? [{ employee: default_employee }] : [], - fields: [ - { - label: __("Employee"), - fieldname: "employee", - fieldtype: "Link", - options: "Employee", - in_list_view: 1, - }, - ], - }, - ], - primary_action_label: __("Start"), - primary_action: (values) => { - dialog.hide(); - me.update_job_card(job_card, "start_timer", { - start_time: values.start_time, - employees: values.employees || [], - }); - }, - }); - dialog.show(); - this.bind_enter_submit(dialog); - } - - // Make a dialog fully keyboard-operable: Enter triggers the primary action, so an operator - // never has to reach for the mouse. Enter is left alone inside multi-line fields and while an - // autocomplete (Link/Select) dropdown is open, so it can still pick a value. - bind_enter_submit(dialog) { - dialog.$wrapper.on("keydown.sfenter", (e) => { - if (e.key !== "Enter" || e.shiftKey) return; - if ($(e.target).is("textarea")) return; - if ($(".awesomplete > ul:not([hidden])").length) return; - const $btn = dialog.get_primary_btn(); - if ( - $btn && - $btn.length && - $btn.is(":visible") && - !$btn.hasClass("disabled") && - !$btn.prop("disabled") - ) { - e.preventDefault(); - e.stopPropagation(); - $btn.trigger("click"); - } - }); - } - - pause_job(jc_name) { - this.update_job_card(jc_name, "pause_job", { end_time: frappe.datetime.now_datetime() }); - } - - resume_job(jc_name) { - this.update_job_card(jc_name, "resume_job", { start_time: frappe.datetime.now_datetime() }); - } - - end_session(jc_name) { - const me = this; - const jc = this.active_jobs.find((j) => j.name === jc_name); - if (!jc) return; - - let pending = flt(jc.for_quantity) - flt(jc.total_completed_qty); - if (flt(jc.pending_qty) > 0) { - pending = flt(jc.pending_qty); - } - - const fields = [ - { - fieldtype: "Float", - label: __("Qty to Manufacture"), - fieldname: "for_quantity", - reqd: 1, - default: pending, - change() { - const d = me.session_dialog; - d.set_value("completed_qty", d.get_value("for_quantity")); - d.set_value("pending_qty", 0); - d.set_value("process_loss_qty", 0); - }, - }, - { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "completed_qty", - reqd: 1, - default: pending, - change() { - const d = me.session_dialog; - const remaining = - flt(d.get_value("for_quantity")) - - flt(d.get_value("completed_qty")) - - flt(d.get_value("process_loss_qty")); - - if (remaining < 0) { - const max_completed_qty = - flt(d.get_value("for_quantity")) - flt(d.get_value("process_loss_qty")); - d.set_value("completed_qty", max_completed_qty); - frappe.throw( - __("Completed Quantity cannot be greater than {0}", [max_completed_qty]) - ); - } - - if (remaining !== flt(d.get_value("pending_qty"))) { - d.set_value("pending_qty", remaining); - } - }, - }, - { - fieldtype: "Float", - label: __("Pending Quantity"), - fieldname: "pending_qty", - default: 0.0, - change() { - const d = me.session_dialog; - const pl = - flt(d.get_value("for_quantity")) - - flt(d.get_value("completed_qty")) - - flt(d.get_value("pending_qty")); - - if (pl < 0) { - d.set_value("pending_qty", 0); - frappe.throw( - __("Pending Quantity cannot be greater than {0}", [ - flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")), - ]) - ); - } - - if (pl !== flt(d.get_value("process_loss_qty"))) { - d.set_value("process_loss_qty", pl); - } - }, - }, - { - fieldtype: "Float", - label: __("Process Loss Quantity"), - fieldname: "process_loss_qty", - default: 0.0, - change() { - const d = me.session_dialog; - const remaining = - flt(d.get_value("for_quantity")) - - flt(d.get_value("completed_qty")) - - flt(d.get_value("process_loss_qty")); - - if (remaining < 0) { - d.set_value("process_loss_qty", 0); - frappe.throw( - __("Process Loss Quantity cannot be greater than {0}", [ - flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")), - ]) - ); - } - - if (remaining !== flt(d.get_value("pending_qty"))) { - d.set_value("pending_qty", remaining); - } - }, - }, - { fieldtype: "Section Break" }, - { - fieldtype: "Datetime", - label: __("End Time"), - fieldname: "end_time", - default: frappe.datetime.now_datetime(), - }, - ]; - - const get_payload = () => { - const data = me.session_dialog.get_values(); - if (!data) return null; - if (flt(data.completed_qty) <= 0) { - frappe.throw(__("Completed Quantity should be greater than 0")); - } - return { - job_card: jc.name, - qty: flt(data.completed_qty), - for_quantity: flt(data.for_quantity), - pending_qty: flt(data.pending_qty), - process_loss_qty: flt(data.process_loss_qty), - end_time: data.end_time, - }; - }; - - const save_and_continue = () => { - const args = get_payload(); - if (!args) return; - me.session_dialog.hide(); - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.save_and_continue", - args: args, - freeze: true, - freeze_message: __("Saving job card..."), - callback: () => me.reload(), - }); - }; - - const finalize_submit = (args) => { - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.complete_and_submit", - args: args, - freeze: true, - freeze_message: __("Submitting job card..."), - callback: (r) => { - me.reload(); - if (r.message && r.message.finished_good) { - me.prompt_manufacture_entry(jc.name); - } - }, - }); - }; - - const submit_session = () => { - const args = get_payload(); - if (!args) return; - me.session_dialog.hide(); - // Guided QC gate: a job card that requires inspection must pass an inline Quality Check - // before it is submitted (mirrors Job Card.validate_inspection on the server). Once the - // inspection is recorded, finalize the session submit. - if (jc.qc && jc.qc.required && jc.qc.status !== "Accepted") { - me.run_quality_check(jc, () => finalize_submit(args)); - } else { - finalize_submit(args); - } - }; - - me.session_dialog = new frappe.ui.Dialog({ - title: __("End Session"), - fields: fields, - primary_action_label: __("Submit"), - primary_action: submit_session, - secondary_action_label: __("Save & Continue"), - secondary_action: save_and_continue, - }); - me.session_dialog.show(); - me.bind_enter_submit(me.session_dialog); - } - - // ── Inline Quality Check ───────────────────────────────────────────────────── - // Fetch the operation's Quality Inspection template and open a guided pass/fail checklist. - // `on_pass` runs once the inspection has been recorded (and is not rejected). - run_quality_check(jc, on_pass) { - const me = this; - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_quality_inspection_checklist", - args: { job_card: jc.name }, - freeze: true, - freeze_message: __("Loading quality checklist..."), - callback: (r) => { - const info = r.message || {}; - if (!info.template || !(info.parameters || []).length) { - // Inspection is required but the operation has no template/parameters to fill — - // there is nothing to capture inline. Point the user at the configuration. - frappe.msgprint({ - title: __("Quality Inspection Template Missing"), - indicator: "orange", - message: __( - "This operation requires a Quality Inspection but no template with parameters is configured. Set a Quality Inspection Template on Operation {0} to inspect from the Shop Floor.", - [jc.operation || ""] - ), - }); - return; - } - me.show_qc_dialog(jc, info, on_pass); - }, - }); - } - - show_qc_dialog(jc, info, on_pass) { - const me = this; - const params = info.parameters || []; - // Per-row operator input, keyed by row index (avoids escaping issues with parameter names). - const state = {}; // idx -> "Accepted" | "Rejected" - - const rows = params - .map((p, i) => { - const spec = frappe.utils.escape_html(p.specification); - let criteria = ""; - if (p.numeric) { - const lo = p.min_value !== null && p.min_value !== undefined ? p.min_value : "−∞"; - const hi = p.max_value !== null && p.max_value !== undefined ? p.max_value : "∞"; - criteria = __("Acceptable range: {0} to {1}", [lo, hi]); - } else if (p.value) { - criteria = __("Expected: {0}", [frappe.utils.escape_html(p.value)]); - } - const control = p.numeric - ? `` - : ` - - - `; - return `
-
-
${spec}
- ${criteria ? `
${criteria}
` : ""} -
-
${control}
-
`; - }) - .join(""); - - const dialog = new frappe.ui.Dialog({ - title: __("Quality Check"), - size: "large", - fields: [ - { - fieldtype: "HTML", - options: `
${__( - "Inspect {0} for job card {1}", - [frappe.utils.escape_html(info.item_code || ""), frappe.utils.escape_html(jc.name)] - )}
${rows}
`, - }, - ], - primary_action_label: __("Submit Inspection"), - primary_action: () => { - const readings = []; - let missing = false; - params.forEach((p, i) => { - if (p.numeric) { - const val = dialog.$wrapper.find(`.mes-qc-reading[data-idx="${i}"]`).val(); - if (val === "" || val === undefined || val === null) missing = true; - readings.push({ specification: p.specification, reading_value: val }); - } else { - if (!state[i]) missing = true; - readings.push({ - specification: p.specification, - status: state[i], - reading_value: "", - }); - } - }); - if (missing) { - frappe.msgprint(__("Please complete every check before submitting the inspection.")); - return; - } - dialog.hide(); - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_quality_inspection", - args: { job_card: jc.name, readings: JSON.stringify(readings) }, - freeze: true, - freeze_message: __("Recording inspection..."), - callback: (r) => { - const res = r.message || {}; - if (res.status === "Rejected") { - // Don't auto-proceed on a rejected inspection — the server gate may block the - // submit anyway (per Stock Settings), and the operator should decide next steps. - frappe.msgprint({ - title: __("Inspection Rejected"), - indicator: "red", - message: __( - "Quality Inspection {0} is Rejected. Resolve the issue or follow your rejection process before submitting the job card.", - [res.name || ""] - ), - }); - me.reload(); - return; - } - if (on_pass) on_pass(); - }, - }); - }, - }); - - dialog.show(); - // Pass/Fail toggles for qualitative parameters. - dialog.$wrapper.find(".mes-qc-passfail button").on("click", function () { - const $btn = $(this); - const $grp = $btn.closest(".mes-qc-passfail"); - $grp.find("button").removeClass("active"); - $btn.addClass("active"); - state[$grp.attr("data-idx")] = $btn.attr("data-val"); - }); - } - - prompt_manufacture_entry(jc_name) { - const me = this; - const dialog = new frappe.ui.Dialog({ - title: __("Job Card Submitted"), - fields: [ - { - fieldtype: "HTML", - options: ` -
-
- ${__("Job card {0} has been submitted.", [frappe.utils.escape_html(jc_name)])} -
-
- ${__("Create a Manufacture stock entry for the finished goods?")} -
-
- `, - }, - ], - primary_action_label: __("Make Manufacture Entry"), - primary_action: () => { - dialog.hide(); - me.make_manufacture_entry(jc_name); - }, - secondary_action_label: __("Skip"), - secondary_action: () => dialog.hide(), - }); - dialog.show(); - this.bind_enter_submit(dialog); - } - - submit_job_card(jc_name) { - const me = this; - frappe.confirm(__("Submit job card {0}? This finalizes the job card.", [jc_name]), () => { - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_job_card", - args: { job_card: jc_name }, - freeze: true, - freeze_message: __("Submitting job card..."), - callback: () => me.reload(), - }); - }); - } - - make_manufacture_entry(jc_name) { - frappe.call({ - method: "erpnext.manufacturing.page.shop_floor.shop_floor.make_manufacture_stock_entry", - args: { job_card: jc_name }, - freeze: true, - freeze_message: __("Preparing stock entry..."), - callback: (r) => { - if (r.message && r.message.name) { - window.open(`/app/stock-entry/${encodeURIComponent(r.message.name)}`, "_blank"); - } - }, - }); - } - - transfer_materials(jc_name) { - if (!jc_name) return; - frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", - args: { source_name: jc_name }, - callback: (r) => { - const doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); - }, - }); - } - - update_job_card(job_card, method, data, on_success) { - const me = this; - frappe.call({ - method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card", - args: { - job_card: job_card, - method: method, - start_time: data.start_time || "", - employees: data.employees || [], - end_time: data.end_time || "", - qty: data.qty || 0, - for_quantity: data.for_quantity || 0, - pending_qty: data.pending_qty || 0, - process_loss_qty: data.process_loss_qty || 0, - auto_submit: data.auto_submit || 0, - }, - freeze: true, - freeze_message: __("Updating job card..."), - callback: () => { - me.reload(); - if (on_success) on_success(); - }, - }); - } - - // ── Timers ──────────────────────────────────────────────────────────────── - start_timer_for(jc, $container) { - let elapsed = this.elapsed_seconds(jc); - this.render_timer(jc.name, elapsed, $container); - this.timer_intervals[jc.name] = setInterval(() => { - elapsed += 1; - this.render_timer(jc.name, elapsed, $container); - }, 1000); - } - - elapsed_seconds(jc) { - let total = 0; - for (const log of jc.time_logs || []) { - if (log.to_time) { - if (log.time_in_mins) { - total += flt(log.time_in_mins, 2) * 60; - } else { - total += moment(log.to_time).diff(log.from_time, "seconds"); - } - } else { - total += moment().diff(log.from_time, "seconds"); - } - } - return total; - } - - render_timer(jc_name, seconds, $container) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds - h * 3600) / 60); - const s = cint(seconds - h * 3600 - m * 60); - const pad = (n) => (n < 10 ? "0" + n : String(n)); - - const scope = $container || this.wrapper; - const timer = scope.find(`.mes-job-timer[data-job-card="${jc_name}"]`); - timer.find(".h").text(pad(h)); - timer.find(".m").text(pad(m)); - timer.find(".s").text(pad(s)); - } - - // ── Realtime + lifecycle ─────────────────────────────────────────────────── - bind_realtime() { - frappe.realtime.on("update_workstation_status", (data) => { - if (data && data.name === this.op_state.workstation) { - this.reload(); - } - }); - } - - bind_lifecycle() { - // Frappe has no on_page_hide hook, so toggle immersive mode + keyboard binding on - // route changes ourselves. - this._route_handler = () => { - const on_page = (frappe.get_route_str() || "").startsWith("shop-floor"); - if (on_page) { - $(document.body).addClass("shop-floor-active"); - this.bind_keys(); - } else { - $(document.body).removeClass("shop-floor-active"); - this.unbind_keys(); - this.clear_timers(); - } - }; - frappe.router.on("change", this._route_handler); - } - - on_show() { - $(document.body).addClass("shop-floor-active"); - this.bind_keys(); - // Cached re-navigation (e.g. the Work Order "Shop Floor" button) lands here with fresh - // route_options; init() handles the very first load before we're initialized. - if (this.initialized) this.apply_route_options(); - } - - // ── Keyboard ──────────────────────────────────────────────────────────────── - bind_keys() { - $(document).off("keydown.shopfloor"); - $(document).on("keydown.shopfloor", (e) => this.handle_key(e)); - } - - unbind_keys() { - $(document).off("keydown.shopfloor"); - } - - is_typing(e) { - const tag = (e.target.tagName || "").toLowerCase(); - return tag === "input" || tag === "textarea" || tag === "select" || e.target.isContentEditable; - } - - handle_key(e) { - // Let dialogs own the keyboard while open. - if ($(".modal:visible").length) return; - - const typing = this.is_typing(e); - - // Escape works even while typing (blur the search / close the detail pane). - if (e.key === "Escape") { - if (typing) { - e.target.blur(); - return; - } - if (this.view === "manager" && this.selected_wo) { - this.close_wo(); - e.preventDefault(); - } - return; - } - - if (typing) return; - - switch (e.key) { - case "?": - this.show_help(); - e.preventDefault(); - return; - case "/": - this.topbar_center.find(".sf-search-input").focus(); - e.preventDefault(); - return; - case "r": - this.refresh(); - e.preventDefault(); - return; - case "b": - this.open_scanner(); - e.preventDefault(); - return; - case "1": - case "2": - if (this.view === "manager" && MANAGER_BUCKETS[cint(e.key) - 1]) { - this.switch_bucket(MANAGER_BUCKETS[cint(e.key) - 1].key); - e.preventDefault(); - } - return; - } - - // View switch chord: "g" then "m"/"o". - if (e.key === "g") { - this._g_pending = true; - setTimeout(() => (this._g_pending = false), 600); - return; - } - if (this._g_pending && (e.key === "m" || e.key === "o")) { - this._g_pending = false; - if (this.can_manage) this.set_view(e.key === "m" ? "manager" : "operator"); - return; - } - - // Navigation. - if (e.key === "ArrowDown" || e.key === "j") { - this.move_focus(1); - e.preventDefault(); - return; - } - if (e.key === "ArrowUp" || e.key === "k") { - this.move_focus(-1); - e.preventDefault(); - return; - } - if (e.key === "Enter") { - this.activate_focus(); - e.preventDefault(); - return; - } - - // Job actions on the focused card — reuse the rendered buttons. - const map = { - s: ".mes-btn-start, .mes-btn-resume", - p: ".mes-btn-pause, .mes-btn-resume", - e: ".mes-btn-end-session", - t: ".mes-btn-transfer", - }; - if (e.key === "S" && e.shiftKey) { - this.click_job_action(".mes-btn-submit"); - e.preventDefault(); - return; - } - if (map[e.key]) { - this.click_job_action(map[e.key]); - e.preventDefault(); - } - } - - // Job actions act on the focused job card (operator view); when the focus is on a board - // work order (manager view with the detail open) they fall back to the detail's active job. - click_job_action(selector) { - const $el = this.focused_el(); - if ($el && $el.attr("data-kind") === "job") { - const $btn = $el.find(selector).filter(":visible").first(); - if ($btn.length) { - $btn.trigger("click"); - return; - } - } - const scope = this.current_op_container(); - if (scope && scope.length) { - const $btn = scope.find(selector).filter(":visible").first(); - if ($btn.length) $btn.trigger("click"); - } - } - - focusables() { - // Manager always navigates the board work orders — even with the detail open, so the - // arrow keys switch work orders. The standalone operator view navigates its job cards. - const scope = this.view === "manager" ? this.board_container : this.current_op_container(); - if (!scope || !scope.length) return $(); - return scope.find("[data-sf-focusable]"); - } - - move_focus(delta) { - const $items = this.focusables(); - if (!$items.length) return; - this.focus_index = Math.max(0, Math.min($items.length - 1, this.focus_index + delta)); - $items.removeClass("sf-focused"); - const $target = $items.eq(this.focus_index); - $target.addClass("sf-focused"); - $target[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); - // Browsing work orders with the detail already open → switch the detail to the focused one. - if (this.view === "manager" && this.selected_wo && $target.attr("data-kind") === "wo") { - this.open_wo($target.attr("data-name")); - } - } - - focused_el() { - const $items = this.focusables(); - if (this.focus_index < 0 || this.focus_index >= $items.length) return null; - return $items.eq(this.focus_index); - } - - activate_focus() { - const $el = this.focused_el(); - if (!$el) return; - if ($el.attr("data-kind") === "wo") { - this.open_wo($el.attr("data-name")); - } else { - // First visible primary button drives the job card (Start / Resume / End Session). - const $btn = $el.find(".btn-primary:visible").first(); - if ($btn.length) $btn.trigger("click"); - } - } - - show_help() { - const rows = [ - ["?", __("Show this help")], - ["/", __("Search work orders")], - ["r", __("Refresh")], - ["b", __("Scan job card")], - ["g then m / o", __("Switch Board / Operator view")], - ["1 / 2", __("Switch board tab")], - ["↑ / ↓ or j / k", __("Move selection")], - ["Enter", __("Open work order / run primary action")], - ["Esc", __("Close detail / blur search")], - ["s", __("Start / Resume job")], - ["p", __("Pause / Resume job")], - ["e", __("End session for active job")], - ["t", __("Transfer materials")], - ["Shift + S", __("Submit focused job card")], - ]; - const html = `
${rows - .map((r) => `
${r[0]}${r[1]}
`) - .join("")}
`; - const d = new frappe.ui.Dialog({ - title: __("Keyboard Shortcuts"), - fields: [{ fieldtype: "HTML", options: html }], - }); - d.show(); - } - - // ── Scanner ────────────────────────────────────────────────────────────── - open_scanner() { - const me = this; - const dialog = new frappe.ui.Dialog({ - title: __("Scan Job Card"), - fields: [ - { - label: __("Scan or enter Job Card"), - fieldname: "job_card", - fieldtype: "Data", - options: "Barcode", - }, - ], - primary_action_label: __("Continue"), - primary_action: (values) => { - if (!values.job_card) return; - dialog.hide(); - me.handle_scanned_job_card(values.job_card); - }, - }); - dialog.show(); - this.bind_enter_submit(dialog); - } - - handle_scanned_job_card(job_card) { - const me = this; - const jc = (this.job_cards || []).find((j) => j.name === job_card); - if (jc) { - me.route_scanned_action(jc); - return; - } - frappe.db.get_value("Job Card", job_card, ["status", "is_paused", "docstatus"]).then((r) => { - const data = r && r.message; - if (!data || !data.status) { - frappe.msgprint(__("Job Card {0} was not found.", [job_card])); - return; - } - if (cint(data.docstatus) === 1) { - frappe.msgprint(__("Job Card {0} is already submitted.", [job_card])); - } else if (cint(data.is_paused)) { - me.resume_job(job_card); - } else if (data.status === "Work In Progress") { - frappe.msgprint( - __( - "Job Card {0} is already running. Open its machine or work order to pause or complete it.", - [job_card] - ) - ); - } else if (data.status === "Completed") { - me.submit_job_card(job_card); - } else { - me.start_job(job_card); - } - }); - } - - route_scanned_action(jc) { - const me = this; - if (jc.docstatus === 1) { - frappe.msgprint(__("Job Card {0} is already submitted.", [jc.name])); - return; - } - if (jc.status === "Completed") { - me.submit_job_card(jc.name); - return; - } - if (jc.is_paused) { - me.resume_job(jc.name); - return; - } - const last_log = jc.time_logs && jc.time_logs.length ? jc.time_logs[jc.time_logs.length - 1] : null; - const is_running = !!(last_log && !last_log.to_time); - if (is_running) { - me.prompt_running_action(jc); - } else { - me.start_job(jc.name); - } - } - - prompt_running_action(jc) { - const me = this; - const dialog = new frappe.ui.Dialog({ - title: __("Job {0} is running", [jc.name]), - fields: [ - { - fieldtype: "HTML", - options: ` -
- ${__("{0} is already in progress. Pause it or complete the session.", [ - frappe.utils.escape_html(jc.finished_good || jc.production_item || jc.name), - ])} -
- `, - }, - ], - primary_action_label: __("Complete"), - primary_action: () => { - dialog.hide(); - me.end_session(jc.name); - }, - secondary_action_label: __("Pause"), - secondary_action: () => { - dialog.hide(); - me.pause_job(jc.name); - }, - }); - dialog.show(); - this.bind_enter_submit(dialog); - } - - // ── Route options (e.g. the Work Order "Shop Floor" button) ──────────────── - apply_route_options() { - const opts = frappe.route_options; - if (!opts || (!opts.work_order && !opts.workstation)) { - return; - } - frappe.route_options = null; - - // A specific work order / machine was requested — show it in the operator view. - this.view = "operator"; - this.render_shell_controls(); - this.render_view(); - Promise.all([ - this.work_order_filter.set_value(opts.work_order || ""), - this.workstation_filter.set_value(opts.workstation || ""), - ]).then(() => this.load_operator()); - } - - // ── Styles ────────────────────────────────────────────────────────────────── - styles() { - return ``; - } -} - -frappe.ui.ShopFloor = ShopFloor; From beed05ac1844c0d775891ded7988b1e87287702d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:08:57 +0530 Subject: [PATCH 3/4] test(manufacturing): isolate quantity split validation --- erpnext/manufacturing/doctype/job_card/test_job_card.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 95e9e8dbfb6..aa9b1e1e651 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1821,13 +1821,13 @@ class TestJobCard(ERPNextTestSuite): jc = frappe.new_doc("Job Card") jc.for_quantity = 5 - jc.validate_complete_job_card_qty( + jc.validate_completion_qty_split( frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) ) self.assertRaises( frappe.ValidationError, - jc.validate_complete_job_card_qty, + jc.validate_completion_qty_split, frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), ) From 317dd18ce57ec5a07b0c6560e1ecd6f724013b23 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:43:00 +0530 Subject: [PATCH 4/4] fix(manufacturing): align quantity split rounding --- erpnext/manufacturing/doctype/job_card/job_card.py | 6 +++++- erpnext/manufacturing/doctype/job_card/test_job_card.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index ba41a7c67fe..572d1f6e290 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1567,7 +1567,11 @@ class JobCard(Document): return precision = self.precision("total_completed_qty") - accounted_qty = flt(kwargs.qty) + flt(kwargs.pending_qty) + flt(kwargs.process_loss_qty) + accounted_qty = flt( + flt(kwargs.qty, precision) + + flt(kwargs.pending_qty, precision) + + flt(kwargs.process_loss_qty, precision) + ) if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision): return diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index aa9b1e1e651..7d70c2e8d90 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1831,6 +1831,12 @@ class TestJobCard(ERPNextTestSuite): frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), ) + self.assertRaises( + frappe.ValidationError, + jc.validate_completion_qty_split, + frappe._dict(for_quantity=1, qty=0.3334, pending_qty=0.3334, process_loss_qty=0.3334), + ) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card"