From a22a7fddba07916f796012136ae64db2781c65e1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:29 +0530 Subject: [PATCH 01/12] fix(job_card): require the previous operation to be manufactured (#57684) * fix(job_card): block next operation until previous operation is manufactured With track semi finished goods, Work Order Operation completed_qty is set from the submitted job cards' total completed qty, so a job card of the next operation could be started and completed even when no Manufacture entry existed for the previous operation. The semi-finished goods it consumes were never produced. Validate the sequence against the qty actually manufactured against the previous operations' job cards (Manufacture entries / Subcontracting Receipts) when the work order tracks semi finished goods. * test(job_card): cover manufactured qty check across previous operations Work order with operations A and B at sequence 1 and C at sequence 2, tracking semi finished goods. C stays blocked while A's job card is submitted but its Manufacture entry is missing, and once A is manufactured for 3, C can only be completed for 3. (cherry picked from commit 3bd33541521d978ca085006091254bb854649859) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/job_card.py | 71 ++++++++- .../doctype/job_card/test_job_card.py | 142 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..b8fa052e801 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1344,18 +1344,58 @@ class JobCard(Document): if data and len(data) > 0: current_operation_qty = flt(data[0].completed_qty) +<<<<<<< HEAD current_operation_qty += flt(self.total_completed_qty) data = frappe.get_all( +======= + for row in self.get_previous_operations(): + if self.track_semi_finished_goods: + self.validate_previous_operation_manufactured_qty(row, current_operation_qty) + else: + self.validate_previous_operation(row, current_operation_qty) + + def get_previous_operations(self): + previous_operations = frappe.get_all( +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) "Work Order Operation", - fields=["operation", "status", "completed_qty", "sequence_id"], + fields=["name", "operation", "status", "completed_qty", "sequence_id"], filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, order_by="sequence_id, idx", ) +<<<<<<< HEAD message = "Job Card {}: As per the sequence of the operations in the work order {}".format( bold(self.name), bold(get_link_to_form("Work Order", self.work_order)) ) +======= + if self.track_semi_finished_goods and previous_operations: + manufactured_qty = self.get_manufactured_qty_per_operation( + [row.name for row in previous_operations] + ) + + for row in previous_operations: + row.manufactured_qty = flt(manufactured_qty.get(row.name)) + + return previous_operations + + def get_manufactured_qty_per_operation(self, operation_ids): + job_card = frappe.qb.DocType("Job Card") + + data = ( + frappe.qb.from_(job_card) + .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .where( + (job_card.work_order == self.work_order) + & (job_card.docstatus == 1) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run() + + return dict(data) +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) for row in data: if not row.completed_qty: @@ -1386,6 +1426,35 @@ class JobCard(Document): ) ) + def validate_previous_operation_manufactured_qty(self, row, current_operation_qty): + manufactured_qty = flt(row.manufactured_qty) + + if not manufactured_qty: + frappe.throw( + _( + "Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}." + ).format( + bold(self.name), + bold(get_link_to_form("Work Order", self.work_order)), + bold(row.operation), + bold(self.operation), + ), + OperationSequenceError, + ) + + if manufactured_qty < current_operation_qty: + frappe.throw( + _( + "The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first." + ).format( + bold(current_operation_qty), + bold(self.operation), + bold(manufactured_qty), + bold(row.operation), + ), + OperationSequenceError, + ) + def validate_work_order(self): if self.is_work_order_closed(): frappe.throw(_("You can't make any changes to Job Card since Work Order is closed.")) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..db79f149345 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -10,7 +10,11 @@ from frappe.utils.data import add_to_date, now, today from erpnext.manufacturing.doctype.job_card.job_card import ( JobCardOverTransferError, +<<<<<<< HEAD OperationMismatchError, +======= + OperationSequenceError, +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) OverlapError, make_corrective_job_card, make_material_request, @@ -1265,6 +1269,144 @@ class TestJobCard(ERPNextTestSuite): 8, ) + def test_semi_fg_sequence_needs_previous_operations_manufactured(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name + rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name + sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name + sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name + fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name + + semi_fg_boms = {} + for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)): + bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1) + bom.append("items", {"item_code": raw_material, "qty": 1}) + bom.insert() + bom.submit() + semi_fg_boms[semi_fg_item] = bom.name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + + operations = [ + { + "operation": "Sequence Check Op A", + "finished_good": sfg1, + "bom_no": semi_fg_boms[sfg1], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op B", + "finished_good": sfg2, + "bom_no": semi_fg_boms[sfg2], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op C", + "finished_good": fg, + "is_final_finished_good": 1, + "sequence_id": 2, + }, + ] + + for row in operations: + row.update( + { + "workstation": "_Test Workstation A", + "finished_good_qty": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + ) + + make_workstation(row) + make_operation(row) + fg_bom.append("operations", row) + + fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3}) + fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + + for row in work_order.operations: + row.time_in_mins = 60 + + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100) + make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100) + + def get_job_card(operation): + return frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", + {"work_order": work_order.name, "operation": operation, "docstatus": 0}, + "name", + ), + ) + + def add_time_log(job_card, day, qty): + job_card.append( + "time_logs", + { + "from_time": f"2024-01-{day} 08:00:00", + "to_time": f"2024-01-{day} 09:00:00", + "completed_qty": qty, + }, + ) + + jc_a = get_job_card("Sequence Check Op A") + jc_a.for_quantity = 3 + add_time_log(jc_a, "01", 3) + jc_a.submit() + + jc_b = get_job_card("Sequence Check Op B") + add_time_log(jc_b, "02", jc_b.for_quantity) + jc_b.submit() + frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() + + jc_c = get_job_card("Sequence Check Op C") + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + self.assertRaises(OperationSequenceError, jc_c.save) + + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + jc_c.reload() + jc_c.for_quantity = 4 + add_time_log(jc_c, "03", 4) + self.assertRaises(OperationSequenceError, jc_c.save) + + jc_c.reload() + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + jc_c.submit() + + self.assertEqual(jc_c.docstatus, 1) + def test_semi_fg_batch_auto_pull_on_manufacture(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From 3907d93f9fe6d0b8667f92d52326729634ca381a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH 02/12] 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 3f2e0c177bdf07ad885ce140c9df8a450f53433c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:31 +0530 Subject: [PATCH 03/12] refactor(job_card): make the completion dialog say what it asks for (#57688) * refactor(job_card): drop the unused make_finished_good handler Nothing triggered it and Job Card has no make_finished_good method to call. * refactor(job_card): make the completion dialog say what it asks for The dialog qty shares the Qty to Manufacture label with the field on the form while it means the current cycle only, its title fell back to the generic Enter Value because frappe.prompt takes four arguments and it was passed five, and nothing on it stated that the three quantities have to add up. Name the cycle in the label, title the dialog after the button that opens it, and describe the split on the fields. Same wording in the shop floor dialog. (cherry picked from commit 0ddf72dae935b6fe221df32d4c1a7ac32e868ce9) # Conflicts: # erpnext/public/js/shop_floor/shop_floor.js --- .../doctype/job_card/job_card.js | 50 +- erpnext/public/js/shop_floor/shop_floor.js | 1750 +++++++++++++++++ 2 files changed, 1756 insertions(+), 44 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..e11e233fc97 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -243,10 +243,11 @@ frappe.ui.form.on("Job Card", { const fields = [ { fieldtype: "Float", - label: __("Qty to Manufacture"), + label: __("Qty to Manufacture in this Cycle"), fieldname: "for_quantity", reqd: 1, default: pending_qty, + description: __("Completed, Pending and Process Loss quantities must add up to this."), change() { const dialog = frm.job_completion_dialog; dialog.set_value("completed_qty", dialog.get_value("for_quantity")); @@ -272,6 +273,7 @@ frappe.ui.form.on("Job Card", { label: __("Pending Quantity"), fieldname: "pending_qty", default: 0.0, + description: __("Qty left for a later cycle or for another job card."), change() { const dialog = frm.job_completion_dialog; const process_loss_qty = @@ -287,6 +289,7 @@ frappe.ui.form.on("Job Card", { fieldtype: "Float", label: __("Process Loss Quantity"), fieldname: "process_loss_qty", + description: __("Qty scrapped in this cycle, nobody will produce it."), onchange() { const dialog = frm.job_completion_dialog; const remaining = @@ -357,9 +360,8 @@ frappe.ui.form.on("Job Card", { }, }); }, - __("Enter Value"), - __("Update"), - __("Set Finished Good Quantity") + __("Complete Job"), + __("Update") ); }, @@ -385,46 +387,6 @@ frappe.ui.form.on("Job Card", { }); }, - make_finished_good(frm) { - const fields = [ - { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "qty", - reqd: 1, - default: frm.doc.for_quantity - frm.doc.manufactured_qty, - }, - { - fieldtype: "Datetime", - label: __("End Time"), - fieldname: "end_time", - default: frappe.datetime.now_datetime(), - }, - ]; - - frappe.prompt( - fields, - (data) => { - if (data.qty <= 0) { - frappe.throw(__("Quantity should be greater than 0")); - } - - frm.call({ - method: "make_finished_good", - doc: frm.doc, - args: { qty: data.qty, end_time: data.end_time }, - callback(r) { - const doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); - }, - }); - }, - __("Enter Value"), - __("Update"), - __("Set Finished Good Quantity") - ); - }, - setup_quality_inspection(frm) { const quality_inspection_field = frm.get_docfield("quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function (frm) { 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..13b8ca657d2 --- /dev/null +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -0,0 +1,1750 @@ +// 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 in this Cycle"), + fieldname: "for_quantity", + reqd: 1, + default: pending, + description: __("Completed, Pending and Process Loss quantities must add up to this."), + 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, + description: __("Qty left for a later cycle or for another job card."), + 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, + description: __("Qty scrapped in this cycle, nobody will produce it."), + 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 d97cf131a18f6108133b54e71009d7c50b32a018 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH 04/12] fix(job_card): apply the completion dialog's qty to manufacture (#57685) * fix(job_card): apply the completion dialog's qty to manufacture Both the desk dialog and the shop floor session dialog send for_quantity when completing a job card, but complete_job_card dropped it. Reducing Qty to Manufacture to 3 on a job card of 5 left for_quantity at 5, so set_process_loss turned the untouched 2 into process loss on the next save. The dialog qty covers the current cycle, so add it to the qty already completed by the earlier cycles of the job card instead of overwriting for_quantity, and validate the pending qty against the result. * test(job_card): cover qty to manufacture from the completion dialog Reducing the dialog qty resizes the job card without inventing process loss, and a pending qty split across two cycles leaves for_quantity untouched. (cherry picked from commit 0e1bc58b2e3078d662612033a8af312e93a7aea0) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- .../doctype/job_card/job_card.py | 20 ++++++ .../doctype/job_card/test_job_card.py | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..f28490e0e87 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1514,8 +1514,28 @@ class JobCard(Document): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + self.set_for_quantity(kwargs) self.validate_complete_job_card_qty(kwargs) +<<<<<<< HEAD +======= + self.pending_qty = flt(kwargs.pending_qty) + self.process_loss_qty = flt(kwargs.process_loss_qty) + + self.add_completion_time_logs(kwargs) + + if kwargs.auto_submit: + self.auto_submit_job_card(kwargs.auto_submit) + + def set_for_quantity(self, kwargs): + """Qty to Manufacture of the completion dialog covers the current cycle only, + so the qty completed by the earlier cycles of this job card is kept.""" + if not flt(kwargs.for_quantity): + return + + self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity) + +>>>>>>> 0e1bc58b2e (fix(job_card): apply the completion dialog's qty to manufacture (#57685)) def validate_docstatus(self): if self.docstatus == 2: frappe.throw(_("Cancelled Job Card cannot be processed.")) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..98251a93ccc 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -888,6 +888,74 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def get_first_job_card(self, work_order): + return frappe.get_doc( + "Job Card", + frappe.get_all( + "Job Card", + filters={"work_order": work_order}, + order_by="sequence_id, creation", + limit=1, + pluck="name", + )[0], + ) + + def test_completion_qty_reduces_for_quantity_without_process_loss(self): + work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=3, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 3) + self.assertEqual(flt(job_card.total_completed_qty), 3) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + def test_completion_qty_keeps_for_quantity_across_cycles(self): + work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-03-02 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=5, + pending_qty=2, + process_loss_qty=0, + end_time="2024-03-02 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.pending_qty), 2) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + job_card.append("time_logs", {"from_time": "2024-03-02 10:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=2, + for_quantity=2, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-02 11:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.total_completed_qty), 5) + self.assertEqual(flt(job_card.process_loss_qty), 0) + def test_op_cost_calculation(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, From 0c7919429e564b3142a5a7fef32098700ae3030a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH 05/12] fix(job_card): leave the pending qty out of the job card's own output (#57686) * fix(job_card): leave the pending qty out of the job card's own output Pending qty is the part of a job card handed over to another job card, but the status and the manufacturing entry still measured the card against its full for_quantity. A card submitted with 3 completed and 2 pending was stuck at Work In Progress with no way to change it, and its manufacturing entry was built for the full 5. Measure both against for_quantity minus pending qty, so the card reaches To Manufacture on submission, its manufacturing entry covers the completed qty, and it is Completed once that qty is manufactured. * test(job_card): cover a job card completed with a pending qty (cherry picked from commit 970039d8ecfca34b255777b69348234b5fdfbdaa) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/job_card.js | 3 +- .../doctype/job_card/job_card.py | 52 ++++ .../doctype/job_card/test_job_card.py | 222 ++++++++++++++++++ 3 files changed, 276 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 0a4026672cf..a19d11adf5d 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -99,7 +99,8 @@ frappe.ui.form.on("Job Card", { doc.docstatus === 1 && !doc.is_subcontracted && (doc.skip_material_transfer || doc.transferred_qty > 0) && - flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity); + flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < + flt(doc.for_quantity) - flt(doc.pending_qty); if (!can_make_stock_entry) return; diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..715941dd5e9 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1280,6 +1280,53 @@ class JobCard(Document): if self.workstation: self.update_workstation_status() +<<<<<<< HEAD +======= + def get_qty_to_produce(self): + """Qty this job card is expected to produce, the pending qty is left to another job card.""" + return flt(self.for_quantity) - flt(self.pending_qty) + + def set_finished_good_status(self): + # Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status(). + qty_to_produce = self.get_qty_to_produce() + + if (self.manufactured_qty + self.process_loss_qty) >= qty_to_produce: + self.status = "Completed" + elif (self.total_completed_qty + self.process_loss_qty) >= qty_to_produce: + # Production is done and the card is submitted, but the finished goods have not been + # booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP. + self.status = "To Manufacture" + elif self.transferred_qty > 0 or self.skip_material_transfer: + self.status = "Work In Progress" + + def set_non_semi_fg_status(self): + if self.items: + item_data = frappe.get_all( + "Job Card Item", + filters={"parent": self.name}, + fields=["transferred_qty", "required_qty"], + ) + all_transferred = item_data and all( + flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data + ) + any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) + + if all_transferred: + self.status = "Material Transferred" + elif any_transferred: + self.status = "Partially Transferred" + elif flt(self.for_quantity) <= flt(self.transferred_qty): + self.status = "Material Transferred" + + if self.time_logs: + self.status = "Work In Progress" + + if self.docstatus == 1 and ( + self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items + ): + self.status = "Completed" + +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) def set_wip_warehouse(self): if not self.wip_warehouse: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") @@ -1578,8 +1625,13 @@ class JobCard(Document): ste = ManufactureEntry( { +<<<<<<< HEAD "for_quantity": self.for_quantity - self.manufactured_qty, "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), +======= + "for_quantity": self.get_qty_to_produce() - self.manufactured_qty, + "process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0), +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..85c73b4ff1a 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,6 +1265,228 @@ class TestJobCard(ERPNextTestSuite): 8, ) +<<<<<<< HEAD +======= + def test_semi_fg_pending_qty_is_left_to_another_job_card(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name + fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1}) + + operation = { + "operation": "Pending Qty Op A", + "workstation": "_Test Workstation A", + "finished_good": fg, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + + make_workstation(operation) + make_operation(operation) + fg_bom.append("operations", operation) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + work_order.operations[0].time_in_mins = 60 + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=5, + pending_qty=2, + process_loss_qty=0, + end_time="2024-04-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.pending_qty), 2) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + job_card.submit() + self.assertEqual(job_card.status, "To Manufacture") + + manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) + finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item) + self.assertEqual(flt(finished_item.qty), 3) + manufacturing_entry.submit() + + job_card.reload() + self.assertEqual(flt(job_card.manufactured_qty), 3) + self.assertEqual(job_card.status, "Completed") + + def test_semi_fg_sequence_needs_previous_operations_manufactured(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name + rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name + sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name + sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name + fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name + + semi_fg_boms = {} + for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)): + bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1) + bom.append("items", {"item_code": raw_material, "qty": 1}) + bom.insert() + bom.submit() + semi_fg_boms[semi_fg_item] = bom.name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + + operations = [ + { + "operation": "Sequence Check Op A", + "finished_good": sfg1, + "bom_no": semi_fg_boms[sfg1], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op B", + "finished_good": sfg2, + "bom_no": semi_fg_boms[sfg2], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op C", + "finished_good": fg, + "is_final_finished_good": 1, + "sequence_id": 2, + }, + ] + + for row in operations: + row.update( + { + "workstation": "_Test Workstation A", + "finished_good_qty": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + ) + + make_workstation(row) + make_operation(row) + fg_bom.append("operations", row) + + fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3}) + fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + + for row in work_order.operations: + row.time_in_mins = 60 + + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100) + make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100) + + def get_job_card(operation): + return frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", + {"work_order": work_order.name, "operation": operation, "docstatus": 0}, + "name", + ), + ) + + def add_time_log(job_card, day, qty): + job_card.append( + "time_logs", + { + "from_time": f"2024-01-{day} 08:00:00", + "to_time": f"2024-01-{day} 09:00:00", + "completed_qty": qty, + }, + ) + + jc_a = get_job_card("Sequence Check Op A") + jc_a.for_quantity = 3 + add_time_log(jc_a, "01", 3) + jc_a.submit() + + jc_b = get_job_card("Sequence Check Op B") + add_time_log(jc_b, "02", jc_b.for_quantity) + jc_b.submit() + frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() + + jc_c = get_job_card("Sequence Check Op C") + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + self.assertRaises(OperationSequenceError, jc_c.save) + + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + jc_c.reload() + jc_c.for_quantity = 4 + add_time_log(jc_c, "03", 4) + self.assertRaises(OperationSequenceError, jc_c.save) + + jc_c.reload() + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + jc_c.submit() + + self.assertEqual(jc_c.docstatus, 1) + +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) def test_semi_fg_batch_auto_pull_on_manufacture(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From 7fcfea6db26f99520618b7c09d866324bb7f86ea Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:42:02 +0530 Subject: [PATCH 06/12] chore: resolve conflict --- .../doctype/job_card/job_card.py | 55 ++++++++----------- .../doctype/job_card/test_job_card.py | 3 - 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b8fa052e801..a2708bb5eac 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1344,31 +1344,19 @@ class JobCard(Document): if data and len(data) > 0: current_operation_qty = flt(data[0].completed_qty) -<<<<<<< HEAD current_operation_qty += flt(self.total_completed_qty) - data = frappe.get_all( -======= - for row in self.get_previous_operations(): - if self.track_semi_finished_goods: - self.validate_previous_operation_manufactured_qty(row, current_operation_qty) - else: - self.validate_previous_operation(row, current_operation_qty) - - def get_previous_operations(self): previous_operations = frappe.get_all( ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) "Work Order Operation", fields=["name", "operation", "status", "completed_qty", "sequence_id"], filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, order_by="sequence_id, idx", ) -<<<<<<< HEAD message = "Job Card {}: As per the sequence of the operations in the work order {}".format( bold(self.name), bold(get_link_to_form("Work Order", self.work_order)) ) -======= + if self.track_semi_finished_goods and previous_operations: manufactured_qty = self.get_manufactured_qty_per_operation( [row.name for row in previous_operations] @@ -1377,27 +1365,11 @@ class JobCard(Document): for row in previous_operations: row.manufactured_qty = flt(manufactured_qty.get(row.name)) - return previous_operations + for row in previous_operations: + if self.track_semi_finished_goods: + self.validate_previous_operation_manufactured_qty(row, current_operation_qty) + continue - def get_manufactured_qty_per_operation(self, operation_ids): - job_card = frappe.qb.DocType("Job Card") - - data = ( - frappe.qb.from_(job_card) - .select(job_card.operation_id, Sum(job_card.manufactured_qty)) - .where( - (job_card.work_order == self.work_order) - & (job_card.docstatus == 1) - & (IfNull(job_card.is_corrective_job_card, 0) == 0) - & (job_card.operation_id.isin(operation_ids)) - ) - .groupby(job_card.operation_id) - ).run() - - return dict(data) ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) - - for row in data: if not row.completed_qty: frappe.throw( _("{0}, complete the operation {1} before the operation {2}.").format( @@ -1426,6 +1398,23 @@ class JobCard(Document): ) ) + def get_manufactured_qty_per_operation(self, operation_ids): + job_card = frappe.qb.DocType("Job Card") + + data = ( + frappe.qb.from_(job_card) + .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .where( + (job_card.work_order == self.work_order) + & (job_card.docstatus == 1) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run() + + return dict(data) + def validate_previous_operation_manufactured_qty(self, row, current_operation_qty): manufactured_qty = flt(row.manufactured_qty) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index db79f149345..4b80d68dd17 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -10,11 +10,8 @@ from frappe.utils.data import add_to_date, now, today from erpnext.manufacturing.doctype.job_card.job_card import ( JobCardOverTransferError, -<<<<<<< HEAD OperationMismatchError, -======= OperationSequenceError, ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) OverlapError, make_corrective_job_card, make_material_request, From f176a4672219f18990e9f8698dae408a04b30c51 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:43:30 +0530 Subject: [PATCH 07/12] chore: resolve conflict --- erpnext/manufacturing/doctype/job_card/job_card.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f28490e0e87..ef71539da71 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1517,16 +1517,6 @@ class JobCard(Document): self.set_for_quantity(kwargs) self.validate_complete_job_card_qty(kwargs) -<<<<<<< HEAD -======= - self.pending_qty = flt(kwargs.pending_qty) - self.process_loss_qty = flt(kwargs.process_loss_qty) - - self.add_completion_time_logs(kwargs) - - if kwargs.auto_submit: - self.auto_submit_job_card(kwargs.auto_submit) - def set_for_quantity(self, kwargs): """Qty to Manufacture of the completion dialog covers the current cycle only, so the qty completed by the earlier cycles of this job card is kept.""" @@ -1535,7 +1525,6 @@ class JobCard(Document): self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity) ->>>>>>> 0e1bc58b2e (fix(job_card): apply the completion dialog's qty to manufacture (#57685)) def validate_docstatus(self): if self.docstatus == 2: frappe.throw(_("Cancelled Job Card cannot be processed.")) From fab480af98d75c4994f99764fcd622e76a38ad3d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:48:19 +0530 Subject: [PATCH 08/12] chore: resolve conflict --- erpnext/public/js/shop_floor/shop_floor.js | 1750 -------------------- 1 file changed, 1750 deletions(-) delete mode 100644 erpnext/public/js/shop_floor/shop_floor.js 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 13b8ca657d2..00000000000 --- a/erpnext/public/js/shop_floor/shop_floor.js +++ /dev/null @@ -1,1750 +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 in this Cycle"), - fieldname: "for_quantity", - reqd: 1, - default: pending, - description: __("Completed, Pending and Process Loss quantities must add up to this."), - 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, - description: __("Qty left for a later cycle or for another job card."), - 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, - description: __("Qty scrapped in this cycle, nobody will produce it."), - 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 09/12] 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 1ededb70f4565dfddfc10b18fb9151d26bbd0f0b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:53:27 +0530 Subject: [PATCH 10/12] chore: resolve conflict --- .../doctype/job_card/job_card.py | 55 +------ .../doctype/job_card/test_job_card.py | 154 ++---------------- 2 files changed, 15 insertions(+), 194 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 715941dd5e9..572ff5f12bd 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1236,7 +1236,7 @@ class JobCard(Document): def set_status(self, update_status=False): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] if self.finished_good and self.docstatus == 1: - if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity: + if (self.manufactured_qty + self.process_loss_qty) >= self.get_qty_to_produce(): self.status = "Completed" elif self.transferred_qty > 0 or self.skip_material_transfer: self.status = "Work In Progress" @@ -1267,7 +1267,8 @@ class JobCard(Document): self.status = "Work In Progress" if self.docstatus == 1 and ( - self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items + self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) + or not self.items ): self.status = "Completed" @@ -1280,53 +1281,10 @@ class JobCard(Document): if self.workstation: self.update_workstation_status() -<<<<<<< HEAD -======= def get_qty_to_produce(self): """Qty this job card is expected to produce, the pending qty is left to another job card.""" return flt(self.for_quantity) - flt(self.pending_qty) - def set_finished_good_status(self): - # Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status(). - qty_to_produce = self.get_qty_to_produce() - - if (self.manufactured_qty + self.process_loss_qty) >= qty_to_produce: - self.status = "Completed" - elif (self.total_completed_qty + self.process_loss_qty) >= qty_to_produce: - # Production is done and the card is submitted, but the finished goods have not been - # booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP. - self.status = "To Manufacture" - elif self.transferred_qty > 0 or self.skip_material_transfer: - self.status = "Work In Progress" - - def set_non_semi_fg_status(self): - if self.items: - item_data = frappe.get_all( - "Job Card Item", - filters={"parent": self.name}, - fields=["transferred_qty", "required_qty"], - ) - all_transferred = item_data and all( - flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data - ) - any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) - - if all_transferred: - self.status = "Material Transferred" - elif any_transferred: - self.status = "Partially Transferred" - elif flt(self.for_quantity) <= flt(self.transferred_qty): - self.status = "Material Transferred" - - if self.time_logs: - self.status = "Work In Progress" - - if self.docstatus == 1 and ( - self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items - ): - self.status = "Completed" - ->>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) def set_wip_warehouse(self): if not self.wip_warehouse: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") @@ -1625,13 +1583,8 @@ class JobCard(Document): ste = ManufactureEntry( { -<<<<<<< HEAD - "for_quantity": self.for_quantity - self.manufactured_qty, - "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), -======= "for_quantity": self.get_qty_to_produce() - self.manufactured_qty, - "process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0), ->>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) + "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 85c73b4ff1a..9a8d978cc1f 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,8 +1265,6 @@ class TestJobCard(ERPNextTestSuite): 8, ) -<<<<<<< HEAD -======= def test_semi_fg_pending_qty_is_left_to_another_job_card(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item @@ -1319,7 +1317,16 @@ class TestJobCard(ERPNextTestSuite): make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100) - job_card = self.get_first_job_card(work_order.name) + job_card = frappe.get_doc( + "Job Card", + frappe.get_all( + "Job Card", + filters={"work_order": work_order.name}, + order_by="sequence_id, creation", + limit=1, + pluck="name", + )[0], + ) job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"}) job_card.save() @@ -1337,7 +1344,7 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(job_card.process_loss_qty), 0) job_card.submit() - self.assertEqual(job_card.status, "To Manufacture") + self.assertEqual(job_card.status, "Work In Progress") manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item) @@ -1348,145 +1355,6 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(job_card.manufactured_qty), 3) self.assertEqual(job_card.status, "Completed") - def test_semi_fg_sequence_needs_previous_operations_manufactured(self): - from erpnext.manufacturing.doctype.operation.test_operation import make_operation - from erpnext.stock.doctype.item.test_item import make_item - - warehouse = "Stores - _TC" - rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name - rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name - sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name - sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name - fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name - - semi_fg_boms = {} - for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)): - bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1) - bom.append("items", {"item_code": raw_material, "qty": 1}) - bom.insert() - bom.submit() - semi_fg_boms[semi_fg_item] = bom.name - - fg_bom = frappe.new_doc( - "BOM", - company="_Test Company", - item=fg, - quantity=1, - with_operations=1, - track_semi_finished_goods=1, - ) - - operations = [ - { - "operation": "Sequence Check Op A", - "finished_good": sfg1, - "bom_no": semi_fg_boms[sfg1], - "sequence_id": 1, - }, - { - "operation": "Sequence Check Op B", - "finished_good": sfg2, - "bom_no": semi_fg_boms[sfg2], - "sequence_id": 1, - }, - { - "operation": "Sequence Check Op C", - "finished_good": fg, - "is_final_finished_good": 1, - "sequence_id": 2, - }, - ] - - for row in operations: - row.update( - { - "workstation": "_Test Workstation A", - "finished_good_qty": 1, - "time_in_mins": 60, - "source_warehouse": warehouse, - "fg_warehouse": warehouse, - "skip_material_transfer": 1, - } - ) - - make_workstation(row) - make_operation(row) - fg_bom.append("operations", row) - - fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3}) - fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3}) - fg_bom.insert() - fg_bom.submit() - - work_order = make_wo_order_test_record( - item=fg, - qty=5, - source_warehouse=warehouse, - fg_warehouse=warehouse, - bom_no=fg_bom.name, - skip_transfer=1, - do_not_save=True, - ) - - for row in work_order.operations: - row.time_in_mins = 60 - - work_order.save() - work_order.submit() - - make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100) - make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100) - - def get_job_card(operation): - return frappe.get_doc( - "Job Card", - frappe.db.get_value( - "Job Card", - {"work_order": work_order.name, "operation": operation, "docstatus": 0}, - "name", - ), - ) - - def add_time_log(job_card, day, qty): - job_card.append( - "time_logs", - { - "from_time": f"2024-01-{day} 08:00:00", - "to_time": f"2024-01-{day} 09:00:00", - "completed_qty": qty, - }, - ) - - jc_a = get_job_card("Sequence Check Op A") - jc_a.for_quantity = 3 - add_time_log(jc_a, "01", 3) - jc_a.submit() - - jc_b = get_job_card("Sequence Check Op B") - add_time_log(jc_b, "02", jc_b.for_quantity) - jc_b.submit() - frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() - - jc_c = get_job_card("Sequence Check Op C") - jc_c.for_quantity = 3 - add_time_log(jc_c, "03", 3) - self.assertRaises(OperationSequenceError, jc_c.save) - - frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() - - jc_c.reload() - jc_c.for_quantity = 4 - add_time_log(jc_c, "03", 4) - self.assertRaises(OperationSequenceError, jc_c.save) - - jc_c.reload() - jc_c.for_quantity = 3 - add_time_log(jc_c, "03", 3) - jc_c.submit() - - self.assertEqual(jc_c.docstatus, 1) - ->>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) def test_semi_fg_batch_auto_pull_on_manufacture(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From beed05ac1844c0d775891ded7988b1e87287702d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:08:57 +0530 Subject: [PATCH 11/12] 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 12/12] 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"