From 03eeb839fb295570680a76670e7d394060ab89f6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:39:42 +0530 Subject: [PATCH 01/43] fix: require FG / Semi FG Item on operations when tracking semi finished goods A BOM with track_semi_finished_goods enabled could be saved with no finished_good on any operation: validate_semi_finished_goods only checked that one row had 'Is Final Finished Good' set, and a list containing None passed the emptiness check. Such a BOM breaks every downstream step. The work order copies the empty finished_good into its operations, job cards inherit it, and Make Stock Entry finally fails with 'Item None not found' because the manufacture entry has no production item. Derive the finished good where it is unambiguous: an operation that references a BOM produces that BOM's item, and the final operation produces the BOM's own item. Otherwise require it on the row, since each operation's job card books its output through it. (cherry picked from commit 3497a6a6bf87d5ee2c80ef20a7b7750a06b658fe) --- erpnext/manufacturing/doctype/bom/bom.py | 13 +++++++++++++ .../doctype/bom_operation/bom_operation.json | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index f744a38d9f0..8d607964e91 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -313,6 +313,19 @@ class BOM(WebsiteGenerator): fg_items = [] for row in self.operations: + if row.bom_no and not row.finished_good: + row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") + + if row.is_final_finished_good and not row.finished_good: + row.finished_good = self.item + + if not row.finished_good: + frappe.throw( + _( + "Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled." + ).format(row.idx, bold(row.operation)), + ) + if not row.is_final_finished_good: continue diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 86fcd7082fd..e6ac3ee474e 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -213,6 +213,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "FG / Semi FG Item", + "mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1", "options": "Item" }, { @@ -307,7 +308,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-25 17:15:42.044630", + "modified": "2026-08-08 12:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", From c23b16751ed6eb6fd9682fd1bd1964ad931da6e6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:12 +0530 Subject: [PATCH 02/43] test: BOM tracking semi finished goods rejects operations without FG item (cherry picked from commit aed7c70b1c78a6240b4acb8515d0d5146363d53e) --- erpnext/manufacturing/doctype/bom/test_bom.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index f40f6bc499e..0c8359264d8 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -811,6 +811,53 @@ class TestBOM(ERPNextTestSuite): for row in bom.items: self.assertEqual(row.stock_uom, "Kg") + @timeout + def test_track_semi_finished_goods_requires_finished_good_on_operations(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + + # the first operation produces nothing derivable: no FG item, no BOM to take it from + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.operations[0].finished_good = sfg_item + bom.insert() + + # the final operation's FG item is derived from the BOM's own item + self.assertEqual(bom.operations[1].finished_good, fg_item) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 99d9d845bd6a1bc332dfa1ca5866087f3268cf1f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:34 +0530 Subject: [PATCH 03/43] fix: don't demand raw material transfer for semi FG job cards on submit validate_transfer_qty uses an empty finished_good to detect legacy job cards, and unlike validate_semi_finished_goods it ignores skip_material_transfer. A job card tracking semi finished goods whose operation had no finished_good fell into the legacy branch and could not be submitted even with 'Skip Material Transfer' checked on the work order. Return early for semi FG job cards; validate_semi_finished_goods already enforces the transfer requirement for them and honours skip_material_transfer. (cherry picked from commit 6c8f0b9b56778349f02ad14b21c1de8ad557f12a) --- erpnext/manufacturing/doctype/job_card/job_card.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..accde70fa97 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -845,6 +845,9 @@ class JobCard(Document): ) def validate_transfer_qty(self): + if self.track_semi_finished_goods: + return + if ( not self.finished_good and not self.is_corrective_job_card From 87e725e43b5c347f5ce1e1f9587f5b27b7bc0bd4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:42:11 +0530 Subject: [PATCH 04/43] test: semi FG job card is exempt from the legacy transfer qty check (cherry picked from commit 4b3904c6d7958469614ff5eede5648a4815563c7) # Conflicts: # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/test_job_card.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..815f2056c41 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1879,3 +1879,113 @@ 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_qty_in_messages_carries_the_uom(self): + jc = frappe.new_doc("Job Card") + jc.stock_uom = "Nos" + + self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos") + self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos") + + 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)) + + def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): + jc = frappe.new_doc("Job Card") + jc.track_semi_finished_goods = 1 + jc.for_quantity = 10 + jc.transferred_qty = 0 + jc.append("items", {"item_code": "_Test Item"}) + + jc.validate_transfer_qty() + + jc.track_semi_finished_goods = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) +>>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) From 24cd5f22b53d7903810649e208bdfe9869eefeee Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:43:22 +0530 Subject: [PATCH 05/43] fix: require WIP warehouse for work orders tracking semi finished goods Work orders with track_semi_finished_goods were exempt from the Work-in-Progress Warehouse requirement in three places: the field's mandatory_depends_on, the fg_warehouse reqd toggle in the form script, and validate_warehouse on submit. The exemption was misleading. The flow still transfers materials to a WIP warehouse when 'Skip Material Transfer' is unchecked: operations default their WIP warehouse from the work order, and set_default_warehouse silently restores the company default after the user clears the field. Make the field genuinely required instead of pretending it is optional. (cherry picked from commit 198eb60df7875d0e4ed300c259ddd1a866c8d418) --- erpnext/manufacturing/doctype/work_order/work_order.js | 3 +-- erpnext/manufacturing/doctype/work_order/work_order.json | 4 ++-- erpnext/manufacturing/doctype/work_order/work_order.py | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 04f259f1508..9992c2466fd 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -281,8 +281,7 @@ frappe.ui.form.on("Work Order", { }, set_fg_warehouse_mandatory(frm) { - let mandatory = frm.doc.skip_transfer === 1 || frm.doc.track_semi_finished_goods === 1 ? false : true; - frm.toggle_reqd("fg_warehouse", mandatory); + frm.toggle_reqd("fg_warehouse", frm.doc.skip_transfer !== 1); }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 04b970be3e1..cfe140726df 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -272,7 +272,7 @@ "fieldtype": "Link", "label": "Work-in-Progress Warehouse", "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", - "mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods", + "mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse", "options": "Warehouse" }, { @@ -739,7 +739,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-06-03 21:35:34.175667", + "modified": "2026-08-08 12:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index eecf06b15c4..a1fd433ad22 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -915,9 +915,6 @@ class WorkOrder(Document): production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) def validate_warehouse(self): - if self.track_semi_finished_goods: - return - if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) if not self.fg_warehouse: From e822efe6a11c8af4d854896db4f5b6aea9c5334a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:44:02 +0530 Subject: [PATCH 06/43] test: WIP warehouse required for work orders tracking semi finished goods (cherry picked from commit f61f6523b963ebed361b7f0d66d6514af0069ce1) # Conflicts: # erpnext/manufacturing/doctype/work_order/test_work_order.py --- .../doctype/work_order/test_work_order.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index fb2e49ed571..d58bb078d2a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4839,6 +4839,71 @@ class TestWorkOrder(ERPNextTestSuite): # generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units) self.assertEqual(flt(row.qty, 6), 3.0) +<<<<<<< HEAD +======= + def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self): + """When one item is transferred both for itself and as a substitute for another required item, + each transfer must be credited to the right required item. + + _material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked + Max(original_item); for item B transferred once for itself (original_item NULL) and once as a + substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving + B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes + each transfer correctly, deterministically on MariaDB and Postgres. + """ + from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService + + source_warehouse = "Stores - _TC" + fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name + item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name + item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name + + # B is a registered alternative for A + if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}): + frappe.get_doc( + { + "doctype": "Item Alternative", + "item_code": item_a, + "alternative_item_code": item_b, + "two_way": 1, + } + ).insert() + + # stock B generously (covers B-for-A plus B-for-itself) + for item, qty in ((item_a, 50), (item_b, 100)): + test_stock_entry.make_stock_entry( + item_code=item, target=source_warehouse, qty=qty, basic_rate=100 + ) + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b]) + wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) + + transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) + transfer.save() + # substitute B for the A line; the existing B line stays as B's own transfer + for d in transfer.items: + if d.item_code == item_a: + d.item_code = item_b + d.original_item = item_a + transfer.submit() + + qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0) + # B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B. + self.assertEqual(flt(qty_by_item.get(item_a)), 10.0) + self.assertEqual(flt(qty_by_item.get(item_b)), 10.0) + + def test_wip_warehouse_required_when_tracking_semi_finished_goods(self): + wo = frappe.new_doc("Work Order") + wo.track_semi_finished_goods = 1 + wo.skip_transfer = 0 + wo.fg_warehouse = "_Test Warehouse 1 - _TC" + + self.assertRaises(frappe.ValidationError, wo.validate_warehouse) + + wo.wip_warehouse = "_Test Warehouse - _TC" + wo.validate_warehouse() + +>>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From 104c8df7656cac89834e673192d4b67f82754462 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:08 +0530 Subject: [PATCH 07/43] fix: stop asking for a manufacturing entry when process loss explains the shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a previous operation manufactured less than the current job card is completing, the error always said 'Submit the manufacturing entry for the operation first' — even when the entry was already submitted and the missing quantity was booked as process loss, which made the advice a dead end. Sum the process loss of the previous operation's job cards alongside the manufactured quantity. When manufactured + process loss covers the requested quantity, say the shortfall is process loss so the user knows to reduce the completed quantity; keep the submit-first message for genuinely pending manufacturing entries. (cherry picked from commit 1e22695eaef905ad141cb0e3f57fc7513e81c1ce) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- .../doctype/job_card/job_card.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index accde70fa97..61577623236 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1342,6 +1342,57 @@ class JobCard(Document): if not (self.work_order and self.sequence_id): return +<<<<<<< HEAD +======= + current_operation_qty = self.get_current_operation_completed_qty() + + 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( + "Work Order Operation", + fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"], + filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, + order_by="sequence_id, idx", + ) + + if self.track_semi_finished_goods and previous_operations: + totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations]) + + for row in previous_operations: + operation_totals = totals.get(row.name) + row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty) + row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty) + + 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).as_("manufactured_qty"), + Sum(job_card.process_loss_qty).as_("process_loss_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(as_dict=True) + + return {row.operation_id: row for row in data} + + def get_current_operation_completed_qty(self): +>>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) current_operation_qty = 0.0 data = self.get_current_operation_data() if data and len(data) > 0: @@ -1377,6 +1428,7 @@ class JobCard(Document): OperationSequenceError, ) +<<<<<<< HEAD if row.completed_qty < current_operation_qty: frappe.throw( _( @@ -1388,6 +1440,49 @@ class JobCard(Document): bold(row.operation), ) ) +======= + 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: + return + + if manufactured_qty + flt(row.process_loss_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}, as {4} was booked as process loss there." + ).format( + bold(self.get_qty_with_uom(current_operation_qty)), + bold(self.operation), + bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), + bold(row.operation), + bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)), + ), + OperationSequenceError, + ) +>>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) + + 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(self.get_qty_with_uom(current_operation_qty)), + bold(self.operation), + bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), + bold(row.operation), + ), + OperationSequenceError, + ) def validate_work_order(self): if self.is_work_order_closed(): From f58c0adbf5ec2f5c3e3a3d3d9cd22c0a332697f6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:43 +0530 Subject: [PATCH 08/43] test: previous operation shortfall from process loss gets the right message (cherry picked from commit 335dbdaca40e2c9639a94648c02feaf7dfd000bb) --- .../doctype/job_card/test_job_card.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 815f2056c41..b5a7a5c6099 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1977,6 +1977,25 @@ class TestJobCardLogic(ERPNextTestSuite): self.assertFalse(jc.has_overlap(2, sequential)) self.assertTrue(jc.has_overlap(2, overlapping)) + def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self): + jc = frappe.new_doc("Job Card") + jc.operation = "_Test Painting" + jc.stock_uom = "Nos" + row = frappe._dict( + operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None + ) + + with self.assertRaises(OperationSequenceError) as loss_error: + jc.validate_previous_operation_manufactured_qty(row, 10) + self.assertIn("process loss", str(loss_error.exception)) + + row.process_loss_qty = 0 + with self.assertRaises(OperationSequenceError) as pending_error: + jc.validate_previous_operation_manufactured_qty(row, 10) + self.assertIn("Submit the manufacturing entry", str(pending_error.exception)) + + jc.validate_previous_operation_manufactured_qty(row, 8) + def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): jc = frappe.new_doc("Job Card") jc.track_semi_finished_goods = 1 From a0b370b2e949548d4e215e80f40e027052711121 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:20:54 +0530 Subject: [PATCH 09/43] fix: scope manufacture entry process loss to its own job card set_process_loss_qty stamped MAX(process_loss_qty) across every operation of the work order onto each manufacture entry. With semi finished goods tracking, one operation's process loss leaked into the entries of every other operation: validate_fg_completed_qty then rejected the entry when it had a BOM, or the wrong loss was recorded silently when it did not, double-counting the loss across operations. When the entry belongs to a job card, use that job card's loss net of what its earlier entries already booked. The MAX fallback stays for work-order level entries without a job card. Fixes frappe/erpnext#57892 (cherry picked from commit 1b335973b7c58c623693d7eec68aafca5581de32) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 6f8a4644a56..258963ed9c5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3192,13 +3192,16 @@ class StockEntry(StockController, SubcontractingInwardController): return precision = self.precision("process_loss_qty") - if self.work_order: - data = frappe.get_all( - "Work Order Operation", - filters={"parent": self.work_order}, - fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], + process_loss_qty = self.get_pending_process_loss_qty() + if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): + self.process_loss_qty = flt(process_loss_qty, precision) + + frappe.msgprint( + _("The Process Loss Qty has been reset as per the job card's Process Loss Qty"), + alert=True, ) +<<<<<<< HEAD if data and data[0].process_loss_qty: process_loss_qty = data[0].process_loss_qty if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): @@ -3208,6 +3211,8 @@ class StockEntry(StockController, SubcontractingInwardController): _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True ) +======= +>>>>>>> 1b335973b7 (fix: scope manufacture entry process loss to its own job card) if not self.process_loss_percentage and not self.process_loss_qty: self.process_loss_percentage = frappe.get_cached_value( "BOM", self.bom_no, "process_loss_percentage" @@ -3222,6 +3227,23 @@ class StockEntry(StockController, SubcontractingInwardController): (flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100 ) + def get_pending_process_loss_qty(self): + """Loss this entry should still book: the job card's unbooked loss when the entry + belongs to one, else the largest operation loss on the work order (legacy flow).""" + if self.job_card: + job_card = frappe.get_doc("Job Card", self.job_card) + return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0) + + if self.work_order: + data = frappe.get_all( + "Work Order Operation", + filters={"parent": self.work_order}, + fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], + ) + return flt(data[0].process_loss_qty) if data else 0 + + return 0 + def set_work_order_details(self): if not getattr(self, "pro_doc", None): self.pro_doc = frappe._dict() From 0de97159ea06ac4ef61ecd48e1616ea6794c9711 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:23:28 +0530 Subject: [PATCH 10/43] test: manufacture entry keeps process loss scoped to its own operation (cherry picked from commit 5e0f056284cd670438a35cb6c5a1cb34e2c32a08) --- .../doctype/job_card/test_job_card.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index b5a7a5c6099..f7834c82cfe 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1402,6 +1402,113 @@ class TestJobCard(ERPNextTestSuite): consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle) self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys())) + def test_manufacture_entry_process_loss_not_taken_from_previous_operation(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("PL Scope RM 1", {"is_stock_item": 1}).name + rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name + sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name + fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm1, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg1, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + operation1 = { + "operation": "PL Scope Op A", + "workstation": "_Test Workstation A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "finished_good_qty": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + operation2 = { + "operation": "PL Scope Op B", + "workstation": "_Test Workstation A", + "finished_good": fg1, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 2, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + make_workstation(operation1) + make_operation(operation1) + make_operation(operation2) + fg_bom.append("operations", operation1) + fg_bom.append("operations", operation2) + fg_bom.append("items", {"item_code": rm2, "qty": 1}) + fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg1, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + ) + + 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) + make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100) + + jc_a = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name" + ), + ) + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3}, + ) + jc_a.pending_qty = 0 + jc_a.process_loss_qty = 2 + jc_a.submit() + me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()) + me_a.submit() + self.assertEqual(flt(me_a.process_loss_qty), 2.0) + + jc_b = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name" + ), + ) + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 2 + jc_b.submit() + me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + + # operation A's loss must not leak into operation B's entry + self.assertEqual(flt(me_b.process_loss_qty), 0.0) + fg_row = next(row for row in me_b.items if row.is_finished_item) + self.assertEqual(flt(fg_row.qty), 3.0) + me_b.submit() + def test_semi_fg_auto_pull_with_uom_conversion(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From 35f9bec988611cb2427a9ad1b6b2b792fbacbbdc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:27:39 +0530 Subject: [PATCH 11/43] fix: add raw material to its operation even when another operation uses the item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_item_details returns the whole Item document, so the dialog row's name became the item code. get_item_data then matched that item code against every Components row regardless of operation, so adding an item already used by another operation silently updated that row's qty instead of appending one for the target operation — which stayed empty and failed 'please add raw materials or set a BOM' on submit. Match the existing row by item code within the same operation: same operation updates the qty, any other match appends a new row. (cherry picked from commit 24f1f3dea88d76fa70ef83a4e20b851fbfc32d7a) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 25 ++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 8d607964e91..54fe063fcf0 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -870,6 +870,27 @@ class BOM(WebsiteGenerator): self.save() +<<<<<<< HEAD +======= + def _add_raw_material_row(self, operation_row_id, row): + row = parse_json(row) + + row.update(get_item_details(row.get("item_code"))) + row.operation_row_id = operation_row_id + + item_row = self.get_item_data(row.item_code, operation_row_id) + + if item_row: + item_row.qty = row.get("qty") + else: + row.idx = None + row.name = None + row.do_not_explode = 1 + row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code) + + self.append("items", row) + +>>>>>>> 24f1f3dea8 (fix: add raw material to its operation even when another operation uses the item) def is_sub_assembly_item(self, item_code): if not self.operations: return False @@ -880,9 +901,9 @@ class BOM(WebsiteGenerator): return False - def get_item_data(self, name): + def get_item_data(self, item_code, operation_row_id): for row in self.items: - if row.item_code == name: + if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id): return row @frappe.whitelist() From b4eceeda2d96debbade43a76627db005f1b9265f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:28:31 +0530 Subject: [PATCH 12/43] test: raw material dialog adds a row for its operation despite duplicates (cherry picked from commit 0aec62a8dd5481f482c6c24a4742e0c2059a17b3) --- erpnext/manufacturing/doctype/bom/test_bom.py | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 0c8359264d8..37e73c4dbe6 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -7,7 +7,7 @@ from functools import partial import frappe from frappe.tests import timeout -from frappe.utils import cstr, flt +from frappe.utils import cint, cstr, flt from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, @@ -858,6 +858,64 @@ class TestBOM(ERPNextTestSuite): # the final operation's FG item is derived from the BOM's own item self.assertEqual(bom.operations[1].finished_good, fg_item) + @timeout + def test_add_raw_materials_when_item_is_used_by_another_operation(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "finished_good": sfg_item, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + bom.insert() + + def rows_for(item_code, operation_row_id): + return [ + row + for row in bom.items + if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id + ] + + # the item already used by operation 1 gets its own new row under operation 2 + bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}]) + self.assertEqual(len(rows_for(rm_item, 2)), 1) + self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0) + self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0) + + # adding it again for the same operation updates the row instead of stacking another + bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}]) + self.assertEqual(len(rows_for(rm_item, 2)), 1) + self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 3e0d0b2d68372a629ffd489882024e9b35736772 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:58:07 +0530 Subject: [PATCH 13/43] fix: derive operation FG items before material expansion, keep the final one the BOM's item The finished_good derivation ran in validate_semi_finished_goods, after set_materials_based_on_operation_bom had already expanded operation BOM materials. A single-pass insert-and-submit (API or import) with bom_no set but finished_good empty skipped the expansion, persisting a submitted BOM without the referenced components. The derivation also let a final operation inherit another item from its bom_no, so downstream job cards would produce the wrong item. Move the derivation into set_operation_finished_goods, called before the expansion, prefer the BOM's own item for the final operation, and reject a final operation whose FG item is not the BOM's item. (cherry picked from commit 1e2e87daaca9d5df11b732a80699b1ae3b896631) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 29 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 54fe063fcf0..e33e81d5aaa 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -282,6 +282,7 @@ class BOM(WebsiteGenerator): self.clear_inspection() self.validate_main_item() self.validate_currency() + self.set_operation_finished_goods() self.set_materials_based_on_operation_bom() self.set_conversion_rate() self.set_plc_conversion_rate() @@ -304,8 +305,23 @@ class BOM(WebsiteGenerator): self.set_fg_cost_allocation() self.validate_total_cost_allocation() +<<<<<<< HEAD if self.docstatus == 1: self.validate_raw_materials_of_operation() +======= + def set_operation_finished_goods(self): + """Fill each operation's FG item where it is unambiguous: the final operation produces + this BOM's item, an operation with a BOM produces that BOM's item. Runs before + set_materials_based_on_operation_bom so derived rows get their materials expanded.""" + if not self.track_semi_finished_goods: + return + + for row in self.operations: + if row.is_final_finished_good and not row.finished_good: + row.finished_good = self.item + elif row.bom_no and not row.finished_good: + row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") +>>>>>>> 1e2e87daac (fix: derive operation FG items before material expansion, keep the final one the BOM's item) def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: @@ -313,12 +329,6 @@ class BOM(WebsiteGenerator): fg_items = [] for row in self.operations: - if row.bom_no and not row.finished_good: - row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") - - if row.is_final_finished_good and not row.finished_good: - row.finished_good = self.item - if not row.finished_good: frappe.throw( _( @@ -329,6 +339,13 @@ class BOM(WebsiteGenerator): if not row.is_final_finished_good: continue + if row.finished_good != self.item: + frappe.throw( + _( + "Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}." + ).format(row.idx, bold(row.operation), bold(self.item)), + ) + fg_items.append(row.finished_good) if not fg_items: From 605821f04c75d15c620684c04f2ca406e3290dda Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:01:36 +0530 Subject: [PATCH 14/43] test: operation BOM materials expand on single-pass submit, final FG must match the BOM item (cherry picked from commit 9ef386dfd2dd7d225fd0db7689825b468a2e70c1) --- erpnext/manufacturing/doctype/bom/test_bom.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 37e73c4dbe6..48eb41fdb11 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -916,6 +916,102 @@ class TestBOM(ERPNextTestSuite): self.assertEqual(len(rows_for(rm_item, 2)), 1) self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0) + @timeout + def test_operation_bom_materials_expand_on_single_pass_submit(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1) + sfg_bom.append("items", {"item_code": rm_item, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "bom_no": sfg_bom.name, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + bom.submit() + + self.assertEqual(bom.docstatus, 1) + self.assertEqual(bom.operations[0].finished_good, sfg_item) + self.assertTrue( + any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items) + ) + + @timeout + def test_final_operation_must_produce_the_bom_item(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "finished_good": sfg_item, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + "finished_good": sfg_item, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + + # the final operation claims to produce the semi FG, not this BOM's item + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.operations[1].finished_good = fg_item + bom.insert() + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 79ad410cc1418cddd0b851a0bb9f04f7714888b1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 15/43] fix: cap a manufacture entry at the job card's pending production Entries from operations without their own BOM carry no For Quantity, so the finished-good reconciliation cannot run for them and a draft created before other entries were submitted could still over-produce. Validate every job-card manufacture entry against the job card directly: finished goods plus process loss must fit in what the job card still has left to produce after earlier submitted entries. (cherry picked from commit 94cd27ce5daf62e7c8b6022953f5e397ed5d67d0) --- .../stock/doctype/stock_entry/stock_entry.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 258963ed9c5..4feabd092c2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -296,6 +296,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_batch() self.validate_inspection() self.validate_fg_completed_qty() + self.validate_job_card_pending_production() self.validate_difference_account() self.set_job_card_data() self.validate_job_card_item() @@ -3227,6 +3228,40 @@ class StockEntry(StockController, SubcontractingInwardController): (flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100 ) + def validate_job_card_pending_production(self): + """A draft created before other entries were submitted must not book more than the job + card still has left; without this, a stale draft over-produces the finished good.""" + if self.purpose != "Manufacture" or not self.job_card: + return + + job_card = frappe.get_doc("Job Card", self.job_card) + if job_card.is_corrective_job_card or job_card.is_subcontracted: + return + + precision = frappe.get_precision("Stock Entry Detail", "qty") + pending_qty = flt( + flt(job_card.get_qty_to_produce()) + - flt(job_card.manufactured_qty) + - flt(job_card.get_consumed_process_loss()), + precision, + ) + finished_qty = flt(sum(flt(d.transfer_qty) for d in self.items if d.is_finished_item), precision) + entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision) + + if entry_qty > pending_qty: + uom = job_card.stock_uom + frappe.throw( + _( + "The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first." + ).format( + frappe.bold(self.job_card), + frappe.bold(f"{pending_qty} {uom}"), + frappe.bold(f"{entry_qty} {uom}"), + f"{finished_qty} {uom}", + f"{flt(self.process_loss_qty, precision)} {uom}", + ) + ) + def get_pending_process_loss_qty(self): """Loss this entry should still book: the job card's unbooked loss when the entry belongs to one, else the largest operation loss on the work order (legacy flow).""" From f25c54e9d7ba2de368b9449ffaa7d0d349119743 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 16/43] test: stale manufacture draft cannot over-produce without an operation BOM (cherry picked from commit 7157e4357b63d8914e76b1462268e0009aa0a51f) --- .../doctype/job_card/test_job_card.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index f7834c82cfe..89b9dfb159b 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1509,6 +1509,114 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(fg_row.qty), 3.0) me_b.submit() + def make_semi_fg_work_order(self, prefix, qty=5): + """Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B + consumes it. Both operations skip material transfer; stock is pre-seeded.""" + 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(f"{prefix} RM 1", {"is_stock_item": 1}).name + rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name + sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name + fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm1, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg1, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + operation1 = { + "operation": f"{prefix} Op A", + "workstation": "_Test Workstation A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "finished_good_qty": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + operation2 = { + "operation": f"{prefix} Op B", + "workstation": "_Test Workstation A", + "finished_good": fg1, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 2, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + make_workstation(operation1) + make_operation(operation1) + make_operation(operation2) + fg_bom.append("operations", operation1) + fg_bom.append("operations", operation2) + fg_bom.append("items", {"item_code": rm2, "qty": 1}) + fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg1, + qty=qty, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + ) + + for item_code in (rm1, rm2, sfg): + make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100) + + return work_order + + def get_semi_fg_job_card(self, work_order, operation): + return frappe.get_doc( + "Job Card", + frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"), + ) + + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): + work_order = self.make_semi_fg_work_order("PL NoBom") + + jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5}, + ) + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + # Op B has no operation BOM, so its entries carry no For Quantity to validate against + jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B") + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 0 + jc_b.process_loss_qty = 2 + jc_b.submit() + + draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + + draft_one.submit() + + stale = frappe.get_doc("Stock Entry", draft_two.name) + self.assertRaises(frappe.ValidationError, stale.submit) + def test_semi_fg_auto_pull_with_uom_conversion(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From 2548751673d7853ce5f282bd32fa1746f3019f20 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:04 +0530 Subject: [PATCH 17/43] fix: generate the next manufacture entry net of booked process loss After a partial entry booked the job card's full process loss, the next generated entry was sized qty-to-produce minus manufactured only. It exceeded the pending production cap, so Make Stock Entry could not finish the card. Subtract the consumed loss when sizing the entry. (cherry picked from commit b8dd886cd424e3347a92a7eff4f357758197f1f3) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- erpnext/manufacturing/doctype/job_card/job_card.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 61577623236..512de2ee2db 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1674,10 +1674,18 @@ class JobCard(Document): from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry +<<<<<<< HEAD ste = ManufactureEntry( { "for_quantity": self.for_quantity - self.manufactured_qty, "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), +======= + consumed_process_loss = self.get_consumed_process_loss() + return ManufactureEntry( + { + "for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss, + "process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0), +>>>>>>> b8dd886cd4 (fix: generate the next manufacture entry net of booked process loss) "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, From e8859028645e4d51dc02a3c07f4d293878819a93 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:05 +0530 Subject: [PATCH 18/43] test: partial manufacture entry then finishing the job card (cherry picked from commit eb7537c8dfab7c7619ba5d450e01601f0513e69a) --- .../doctype/job_card/test_job_card.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 89b9dfb159b..7160e067653 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1588,6 +1588,44 @@ class TestJobCard(ERPNextTestSuite): frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"), ) + def test_partial_manufacture_entry_then_finish(self): + work_order = self.make_semi_fg_work_order("PL Partial") + + jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5}, + ) + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B") + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 0 + jc_b.process_loss_qty = 2 + jc_b.submit() + + # book 1 of the 3 finished units now; the full process loss goes with this first entry + first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + fg_row = next(row for row in first.items if row.is_finished_item) + fg_row.qty = 1 + first.save() + first.submit() + + # the follow-up entry must be generated net of the already-booked loss and still submit + jc_b.reload() + second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + fg_row = next(row for row in second.items if row.is_finished_item) + self.assertEqual(flt(fg_row.qty), 2.0) + self.assertEqual(flt(second.process_loss_qty), 0.0) + second.submit() + + jc_b.reload() + self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): work_order = self.make_semi_fg_work_order("PL NoBom") From c598cf9010b41f1549c0b3f2fd2a5c562f0b9fa8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:06 +0530 Subject: [PATCH 19/43] fix: keep Target Warehouse optional for work orders tracking semi finished goods The WIP warehouse change also removed the Target Warehouse exemption for semi FG orders, but those may validly carry the target on each operation instead. Restore the exemption in the form and the submit check; the WIP warehouse requirement stays. (cherry picked from commit 9df527bf3f98ceeac4545b17452d5b9e010d104a) --- erpnext/manufacturing/doctype/work_order/work_order.js | 3 ++- erpnext/manufacturing/doctype/work_order/work_order.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 9992c2466fd..04f259f1508 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -281,7 +281,8 @@ frappe.ui.form.on("Work Order", { }, set_fg_warehouse_mandatory(frm) { - frm.toggle_reqd("fg_warehouse", frm.doc.skip_transfer !== 1); + let mandatory = frm.doc.skip_transfer === 1 || frm.doc.track_semi_finished_goods === 1 ? false : true; + frm.toggle_reqd("fg_warehouse", mandatory); }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a1fd433ad22..30ed33a66a4 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -917,7 +917,7 @@ class WorkOrder(Document): def validate_warehouse(self): if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) - if not self.fg_warehouse: + if not self.fg_warehouse and not self.track_semi_finished_goods: frappe.throw(_("Target Warehouse is required before Submit")) def before_submit(self): From e252329df4d95c1a3082ce5ec9cd747859df3c5a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:07 +0530 Subject: [PATCH 20/43] test: target warehouse stays optional for semi FG work orders (cherry picked from commit db99657c470ddf4c0a55b92e2e723ff54c16047d) # Conflicts: # erpnext/manufacturing/doctype/work_order/test_work_order.py --- .../doctype/work_order/test_work_order.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index d58bb078d2a..3e5304180bf 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4903,7 +4903,17 @@ class TestWorkOrder(ERPNextTestSuite): wo.wip_warehouse = "_Test Warehouse - _TC" wo.validate_warehouse() +<<<<<<< HEAD >>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) +======= + # the top-level target warehouse stays optional; operations may carry their own + wo.fg_warehouse = None + wo.validate_warehouse() + + wo.track_semi_finished_goods = 0 + self.assertRaises(frappe.ValidationError, wo.validate_warehouse) + +>>>>>>> db99657c47 (test: target warehouse stays optional for semi FG work orders) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From 491f9fa3fe54891744fe3ac45ae488ae179ec363 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:28 +0530 Subject: [PATCH 21/43] fix: skip the pending production check on update-after-submit saves Saving a submitted manufacture entry to change an allowed field re-ran the pending production cap with a manufactured aggregate that already includes the entry itself, so the save was rejected against the post-entry remainder. Quantities are not editable after submit, so the check has nothing to protect there. (cherry picked from commit bed957fa677c76b9c1e2adca80aeb5910e9380cc) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 4feabd092c2..36da536c328 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3234,6 +3234,9 @@ class StockEntry(StockController, SubcontractingInwardController): if self.purpose != "Manufacture" or not self.job_card: return + if self._action == "update_after_submit": + return + job_card = frappe.get_doc("Job Card", self.job_card) if job_card.is_corrective_job_card or job_card.is_subcontracted: return From a815a756b770d4401a6b28a4c3efefd22be0e9b0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:29 +0530 Subject: [PATCH 22/43] test: update-after-submit save keeps the manufacture entry intact (cherry picked from commit 424a1dfa87ffb063498540e10c9199c5cadd5c17) --- .../doctype/job_card/test_job_card.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 7160e067653..f0a1931e689 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1626,6 +1626,31 @@ class TestJobCard(ERPNextTestSuite): jc_b.reload() self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + def test_update_after_submit_keeps_manufacture_entry_intact(self): + work_order = self.make_semi_fg_work_order("PL Update") + + jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3}, + ) + jc_a.pending_qty = 0 + jc_a.process_loss_qty = 2 + jc_a.submit() + + entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()) + entry.submit() + + if not frappe.db.exists("Print Heading", "_Test SFG Heading"): + frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert() + + entry.reload() + entry.select_print_heading = "_Test SFG Heading" + entry.save() + + entry.reload() + self.assertEqual(flt(entry.process_loss_qty), 2.0) + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): work_order = self.make_semi_fg_work_order("PL NoBom") From acba9945b0221d7fa32b714a600e0be0fa845fc5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:07 +0530 Subject: [PATCH 23/43] fix: scale generated raw materials to the manufacture entry's production share Every generated entry copied each Job Card Item's full required_qty in the skip-transfer and BOM-backflush paths, so two entries for one job card consumed the requirement twice. Scale the rows to the share of production this entry accounts for and cap them at the requirement still unconsumed, dropping rows that have nothing left. An entry whose materials are exhausted then fails the existing at-least-one-raw-material check instead of minting finished goods from nothing. (cherry picked from commit 0428cddf5b61a3f71dd105dfe233294730fc9ebe) --- .../stock_entry_type/stock_entry_type.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index 6a809d6f1b2..74996b96a22 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -125,6 +125,7 @@ class ManufactureEntry: if backflush_based_on != "BOM": available_serial_batches = self.get_transferred_serial_batches() + production_share = self.get_production_share() for item_code, _dict in item_dict.items(): _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.to_warehouse = "" @@ -138,11 +139,29 @@ class ManufactureEntry: _dict.qty = calculated_qty self.update_available_serial_batches(_dict, available_serial_batches) - elif self.skip_material_transfer: - set_previous_operation_serial_batch(self.stock_entry, _dict) + else: + remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0) + _dict.qty = min(flt(_dict.qty) * production_share, remaining_qty) + if not _dict.qty: + continue + + if self.skip_material_transfer: + set_previous_operation_serial_batch(self.stock_entry, _dict) self.stock_entry.add_to_stock_entry_detail(item_dict) + def get_production_share(self): + """Fraction of the job card's production this entry accounts for; raw materials are + generated proportionally so several partial entries never consume more than required.""" + for_quantity, pending_qty = frappe.db.get_value( + "Job Card", self.job_card, ["for_quantity", "pending_qty"] + ) + qty_to_produce = flt(for_quantity) - flt(pending_qty) + if not qty_to_produce: + return 1 + + return min(flt(self.for_quantity) / qty_to_produce, 1) + def parse_available_serial_batches(self, item_dict, available_serial_batches): key = (item_dict.item_code, item_dict.from_warehouse) if key not in available_serial_batches: From 5f391a2531c51f29bfbc69052bc408d9425b1176 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:08 +0530 Subject: [PATCH 24/43] test: partial entries consume exactly the job card's material requirement (cherry picked from commit 8f0617c83470d958f79b557d651cdc5fa925bbd2) --- .../doctype/job_card/test_job_card.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index f0a1931e689..7aacb300ec2 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1608,10 +1608,14 @@ class TestJobCard(ERPNextTestSuite): jc_b.process_loss_qty = 2 jc_b.submit() - # book 1 of the 3 finished units now; the full process loss goes with this first entry + # book 1 of the 3 finished units now; the full process loss goes with this first entry, + # so it accounts for 3 of 5 and its materials are trimmed to the same share first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) fg_row = next(row for row in first.items if row.is_finished_item) fg_row.qty = 1 + for row in first.items: + if row.s_warehouse and not row.is_finished_item: + row.qty = flt(row.qty) * 3 / 5 first.save() first.submit() @@ -1626,6 +1630,17 @@ class TestJobCard(ERPNextTestSuite): jc_b.reload() self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + # across both entries, consumption adds up to the job card's requirement of 5, no more + consumed = frappe.get_all( + "Stock Entry Detail", + filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]}, + fields=["item_code", {"SUM": "qty", "as": "qty"}], + group_by="item_code", + ) + self.assertTrue(consumed) + for row in consumed: + self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries") + def test_update_after_submit_keeps_manufacture_entry_intact(self): work_order = self.make_semi_fg_work_order("PL Update") From f810d780c01339174c4e151e422bb44139f05a76 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:34 +0530 Subject: [PATCH 25/43] fix: keep the transfer qty check for legacy semi FG cards without an FG item Existing submitted BOMs may carry operations without a finished good, and no migration repairs them. Exempting every semi FG job card from the transfer check let such a card submit after a partial transfer. Exempt only cards that skip material transfer; legacy cards with transfer enabled keep the strict transferred qty check. (cherry picked from commit 1deae664ce75cea04c344069d8fee8840cfdff8f) --- erpnext/manufacturing/doctype/job_card/job_card.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 512de2ee2db..8ed1897d51e 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -845,7 +845,7 @@ class JobCard(Document): ) def validate_transfer_qty(self): - if self.track_semi_finished_goods: + if self.track_semi_finished_goods and self.skip_material_transfer: return if ( From ae00a09cdf672feb1681121d02e45770a081180f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:35 +0530 Subject: [PATCH 26/43] test: transfer qty exemption only applies when material transfer is skipped (cherry picked from commit 1478e2a4cb24639694d8ac81ee7b6d285f319c64) --- erpnext/manufacturing/doctype/job_card/test_job_card.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 7aacb300ec2..a0ea71aaab0 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2292,12 +2292,21 @@ class TestJobCardLogic(ERPNextTestSuite): def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): jc = frappe.new_doc("Job Card") jc.track_semi_finished_goods = 1 + jc.skip_material_transfer = 1 jc.for_quantity = 10 jc.transferred_qty = 0 jc.append("items", {"item_code": "_Test Item"}) jc.validate_transfer_qty() + # with transfer enabled, a legacy card without an FG item keeps the strict check + jc.skip_material_transfer = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) + + jc.finished_good = "_Test Item" + jc.validate_transfer_qty() + + jc.finished_good = None jc.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) >>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) From 27130d8e49f1e283594ab3f7d5d71d48e35740de Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 27/43] fix: roll up process loss to the work order for semi finished goods update_work_order_qty() returns early when track_semi_finished_goods is enabled, so set_process_loss_qty() never ran and Work Order.process_loss_qty stayed at zero even though the job cards and the work order operations had booked the loss. The work order also never reached the Completed status, since that needs produced_qty + process_loss_qty to cover the ordered qty. Calling set_process_loss_qty() from that early return is not enough: the final operation has no semi finished good bom, so its manufacture entry is not from a bom, remove_fg_completed_qty() zeroes fg_completed_qty and update_work_order_qty() is never reached at all. The manufacture entries cannot be summed either. Each one is reset to MAX(Work Order Operation.process_loss_qty), so every entry of a multi operation chain carries the running maximum instead of the loss of its own operation. Aggregate the operations instead, and refresh the work order from the job card, which is where the operation loss is written. (cherry picked from commit 0eb61c9fac7f685de303288446cb32375c37b02d) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/work_order/services/status.py --- .../doctype/job_card/job_card.py | 25 + .../doctype/work_order/services/status.py | 471 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 erpnext/manufacturing/doctype/work_order/services/status.py diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8ed1897d51e..970f81d5e03 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1041,7 +1041,32 @@ class JobCard(Document): ) def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): +<<<<<<< HEAD workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") +======= + time_data = self.get_operation_time_data() + + for data in wo.operations: + if data.get("name") == self.operation_id: + self.update_wo_operation_row( + data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data + ) + + wo.flags.ignore_validate_update_after_submit = True + wo.update_operation_status() + wo.calculate_operating_cost() + wo.set_actual_dates() + + if wo.track_semi_finished_goods: + wo.set_process_loss_qty() + + if time_data: + wo.status = "In Process" + + wo.save() + + def get_operation_time_data(self): +>>>>>>> 0eb61c9fac (fix: roll up process loss to the work order for semi finished goods) jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py new file mode 100644 index 00000000000..74f204acd40 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -0,0 +1,471 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Status and quantity-rollup logic for Work Order. + +Extracted from work_order.py. ``StatusService`` wraps a Work Order document +(composition); work_order.py keeps thin delegating stubs so the many external +callers (job cards, sales orders, production plans, patches) keep working. +""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import cint, flt, get_link_to_form + +from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty + +_QTY_PURPOSES = ( + ("Manufacture", "produced_qty"), + ("Material Transfer for Manufacture", "material_transferred_for_manufacturing"), + ("Material Transfer for Manufacture", "additional_transferred_qty"), +) + + +class StatusService: + def __init__(self, doc): + self.doc = doc + + def validate_work_order_against_so(self): + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + + total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty) + so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty()) + allowance_percentage = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order") + ) + if total_qty <= so_qty + (allowance_percentage / 100 * so_qty): + return + + frappe.throw( + _("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format( + get_link_to_form("Item", self.doc.production_item), + frappe.bold(so_qty), + frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")), + ), + OverProductionError, + ) + + def _ordered_qty_against_so(self): + wo = frappe.qb.DocType("Work Order") + return ( + frappe.qb.from_(wo) + .select(Sum(wo.qty - wo.process_loss_qty)) + .where( + (wo.production_item == self.doc.production_item) + & (wo.sales_order == self.doc.sales_order) + & (wo.docstatus == 1) + & (wo.status != "Closed") + & (wo.name != self.doc.name) + ) + ).run()[0][0] + + def _so_item_qty(self): + so_item = frappe.qb.DocType("Sales Order Item") + return ( + frappe.qb.from_(so_item) + .select(Sum(so_item.stock_qty)) + .where( + (so_item.parent == self.doc.sales_order) + & (so_item.item_code == self.doc.production_item) + & (so_item.docstatus == 1) + ) + ).run()[0][0] + + def _packed_item_qty(self): + packed_item = frappe.qb.DocType("Packed Item") + return ( + frappe.qb.from_(packed_item) + .select(Sum(packed_item.qty)) + .where( + (packed_item.parent == self.doc.sales_order) + & (packed_item.parenttype == "Sales Order") + & (packed_item.item_code == self.doc.production_item) + & (packed_item.docstatus == 1) + ) + ).run()[0][0] + + def update_status(self, status=None): + """Update status of work order if unknown""" + if self.doc.docstatus == 1: + # Refresh material_transferred_for_manufacturing before deciding status so pick-list- + # driven transfers (where this qty is derived from item transfers, not fg_completed_qty) + # are reflected immediately, instead of only after the next status update call. + self.doc.refresh_material_transferred_for_manufacturing() + + if self.doc.status != "Closed": + if status not in ["Stopped", "Closed"]: + status = self.get_status(status) + + if status != self.doc.status: + self.doc.db_set("status", status) + + self.doc.update_required_items() + + return status or self.doc.status + + def get_status(self, status=None): + """Return the status based on stock entries against this work order""" + status = status or self.doc.status + + if self.doc.docstatus == 0: + status = "Draft" + elif self.doc.docstatus == 1: + status = self._submitted_status(status) + else: + status = "Cancelled" + + if self._is_partial_skip_transfer(): + status = "In Process" + + if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations): + status = "In Process" + + if status == "Not Started" and self.doc.reserve_stock: + status = self._reservation_status(status) + + return status + + def _submitted_status(self, status): + if status in ["Closed", "Stopped"]: + return status + + status = ( + "In Process" + if flt(self.doc.material_transferred_for_manufacturing) > 0 + or self.doc.skip_transfer + or self._has_transferred_material() + else "Not Started" + ) + precision = frappe.get_precision("Work Order", "produced_qty") + total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision) + if flt(total_qty, precision) >= flt(self.doc.qty, precision): + status = "Completed" + return status + + def _has_transferred_material(self): + """True if any raw material was transferred against this work order via a pick list + or a material request (these leave material_transferred_for_manufacturing at 0 via + the min-fraction rule).""" + ste = frappe.qb.DocType("Stock Entry") + ste_child = frappe.qb.DocType("Stock Entry Detail") + mr_child = frappe.qb.DocType("Stock Entry Detail") + # Stock Entry only carries `material_request` at the child-row level, so a Stock + # Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once + # that's established, sum every row's transfer_qty, not just the linked ones (a + # manually appended extra row on the same entry has no material_request of its own). + mr_sourced_stock_entries = ( + frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull()) + ) + qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where( + (ste.work_order == self.doc.name) + & (ste.docstatus == 1) + & (ste.purpose == "Material Transfer for Manufacture") + & (ste.is_return == 0) + & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) + ) + ).run()[0][0] + return flt(qty) > 0 + + def _is_partial_skip_transfer(self): + return bool( + self.doc.skip_transfer + and self.doc.produced_qty + and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty)) + ) + + def _reservation_status(self, status): + for row in self.doc.required_items: + if not row.stock_reserved_qty: + continue + + if row.stock_reserved_qty >= row.required_qty: + status = "Stock Reserved" + else: + return "Stock Partially Reserved" + return status + + def update_work_order_qty(self): + """Update Manufactured Qty and Material Transferred for Qty based on Stock Entry""" + if self.doc.track_semi_finished_goods: + return + + for purpose, fieldname in _QTY_PURPOSES: + self._update_qty_for_purpose(purpose, fieldname) + + if self.doc.production_plan: + self.set_produced_qty_for_sub_assembly_item() + self.update_production_plan_status() + + if self.doc.additional_transferred_qty: + self.doc.validate_additional_transferred_qty() + + def _update_qty_for_purpose(self, purpose, fieldname): + from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError + + if self._skip_transfer_purpose(purpose): + return + + qty = self.get_transferred_or_manufactured_qty(purpose, fieldname) + completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty) + if qty > completed_qty: + frappe.throw( + _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format( + _(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name + ), + StockOverProductionError, + ) + + self.doc.db_set(fieldname, qty) + self.set_process_loss_qty() + self._update_produced_qty_in_so() + + def _skip_transfer_purpose(self, purpose): + return bool( + purpose == "Material Transfer for Manufacture" + and self.doc.operations + and self.doc.transfer_material_against == "Job Card" + ) + + def _qty_allowance(self, purpose): + allowance = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") + ) + if not allowance and purpose == "Material Transfer for Manufacture": + allowance = flt( + frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") + ) + return allowance + + def _update_produced_qty_in_so(self): + from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item + + if ( + self.doc.sales_order + and self.doc.sales_order_item + and not self.doc.production_plan_sub_assembly_item + ): + update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item) + + def update_disassembled_qty(self, qty, is_cancel=False): + if is_cancel: + self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty) + else: + if self.doc.docstatus == 1: + self.doc.disassembled_qty += qty + + if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty: + frappe.throw(_("Cannot disassemble more than produced quantity.")) + + self.doc.db_set("disassembled_qty", self.doc.disassembled_qty) + + def get_transferred_or_manufactured_qty(self, purpose, fieldname): + parent = frappe.qb.DocType("Stock Entry") + is_additional = cint(fieldname == "additional_transferred_qty") + query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional)) + + if purpose == "Manufacture": + child = frappe.qb.DocType("Stock Entry Detail") + query = ( + query.join(child) + .on(parent.name == child.parent) + .select(Sum(child.transfer_qty)) + .where(child.is_finished_item == 1) + ) + else: + query = query.select(Sum(parent.fg_completed_qty)) + + return flt(query.run()[0][0]) + + def _stock_entry_filter(self, parent, purpose, is_additional): + return ( + (parent.work_order == self.doc.name) + & (parent.docstatus == 1) + & (parent.purpose == purpose) + & (parent.is_additional_transfer_entry == is_additional) + ) + + def set_process_loss_qty(self): + self.doc.db_set("process_loss_qty", self._process_loss_qty()) + + def _process_loss_qty(self): + if self.doc.track_semi_finished_goods: + return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations)) + + table = frappe.qb.DocType("Stock Entry") + process_loss_qty = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where( + (table.work_order == self.doc.name) + & (table.purpose == "Manufacture") + & (table.docstatus == 1) + ) + ).run()[0][0] + + return flt(process_loss_qty) + + def update_production_plan_status(self): + production_plan = frappe.get_doc("Production Plan", self.doc.production_plan) + produced_qty = 0 + if self.doc.production_plan_item: + total_qty = frappe.get_all( + "Work Order", + fields=[{"SUM": "produced_qty", "as": "produced_qty"}], + filters={ + "docstatus": 1, + "production_plan": self.doc.production_plan, + "production_plan_item": self.doc.production_plan_item, + }, + as_list=1, + ) + + produced_qty = total_qty[0][0] if total_qty else 0 + + self.update_status() + production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item) + + def update_planned_qty(self): + if self.doc.track_semi_finished_goods: + return + + update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict()) + + if self.doc.material_request: + mr_obj = frappe.get_doc("Material Request", self.doc.material_request) + mr_obj.update_requested_qty([self.doc.material_request_item]) + + def _planned_qty_dict(self): + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_reserved_qty_for_sub_assembly, + ) + + qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)} + if self.doc.production_plan_sub_assembly_item and self.doc.production_plan: + qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly( + self.doc.production_item, self.doc.fg_warehouse + ) + return qty_dict + + def set_produced_qty_for_sub_assembly_item(self): + produced_qty = self._sub_assembly_produced_qty() + frappe.db.set_value( + "Production Plan Sub Assembly Item", + self.doc.production_plan_sub_assembly_item, + "wo_produced_qty", + produced_qty, + ) + + def _sub_assembly_produced_qty(self): + table = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(table) + .select(Sum(table.produced_qty)) + .where( + (table.production_plan == self.doc.production_plan) + & (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item) + & (table.docstatus == 1) + ) + ).run() + return flt(query[0][0]) if query else 0 + + def update_ordered_qty(self): + if not ( + self.doc.production_plan + and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item) + ): + return + + qty = self._production_plan_ordered_qty() + if self.doc.production_plan_item: + frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty) + elif self.doc.production_plan_sub_assembly_item: + field = self.doc.production_plan_sub_assembly_item + frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty) + + doc = frappe.get_doc("Production Plan", self.doc.production_plan) + doc.set_status() + doc.db_set("status", doc.status) + + def _production_plan_ordered_qty(self): + table = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty)) + .where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1)) + ) + if self.doc.production_plan_item: + query = query.where(table.production_plan_item == self.doc.production_plan_item) + elif self.doc.production_plan_sub_assembly_item: + query = query.where( + table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item + ) + + result = query.run() + return flt(result[0][0]) if result else 0 + + def update_work_order_qty_in_so(self): + if ( + not self.doc.sales_order and not self.doc.sales_order_item + ) or self.doc.production_plan_sub_assembly_item: + return + + total_bundle_qty = self._total_bundle_qty() + work_order_qty = self._sales_order_work_order_qty() + frappe.db.set_value( + "Sales Order Item", + self.doc.sales_order_item, + "work_order_qty", + flt(work_order_qty / total_bundle_qty, 2), + ) + + def _sales_order_work_order_qty(self): + wo = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(wo) + .select(Sum(wo.qty)) + .where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed")) + ) + if self.doc.product_bundle_item: + query = query.where(wo.product_bundle_item == self.doc.product_bundle_item) + else: + query = query.where(wo.production_item == self.doc.production_item) + + qty = query.run(as_list=1) + return qty[0][0] if qty and qty[0][0] else 0 + + def update_work_order_qty_in_combined_so(self): + total_bundle_qty = self._total_bundle_qty() + prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan) + item_reference = frappe.get_value( + "Production Plan Item", self.doc.production_plan_item, "sales_order_item" + ) + + for plan_reference in prod_plan.prod_plan_references: + if plan_reference.item_reference != item_reference: + continue + + qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0 + frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty) + + def _total_bundle_qty(self): + if not self.doc.product_bundle_item: + return 1 + + pbi = frappe.qb.DocType("Product Bundle Item") + total_bundle_qty = ( + frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item) + ).run()[0][0] + # product bundle is 0 (product bundle allows 0 qty for items) + return total_bundle_qty or 1 + + def update_completed_qty_in_material_request(self): + if self.doc.material_request and self.doc.material_request_item: + frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty( + [self.doc.material_request_item] + ) From 27625a6f1fe5e0a2c7e70b223e64bfc54118a255 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 28/43] test: work order process loss for semi finished goods Cover both shapes: a single operation that books the loss itself, and a chain where an earlier operation books it and the final operation loses nothing, so the sum over the operations is the only correct source. (cherry picked from commit 24de81f9faca2abac250bf580b36e03ea226d11e) # Conflicts: # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/test_job_card.py | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index a0ea71aaab0..c1f48953e5b 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,6 +1265,426 @@ 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_process_loss_rolls_up_to_work_order(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("Process Loss Rollup RM 1", {"is_stock_item": 1}).name + fg = make_item("Process Loss Rollup 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": "Process Loss Rollup 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=10, + 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-05-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=8, + for_quantity=10, + pending_qty=0, + process_loss_qty=2, + end_time="2024-05-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.process_loss_qty), 2) + + job_card.submit() + frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit() + + self.assertEqual( + flt( + frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty") + ), + 2, + ) + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 8) + self.assertEqual(flt(work_order.process_loss_qty), 2) + self.assertEqual(work_order.status, "Completed") + + def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self): + """Loss booked by an earlier operation shrinks what the final operation can produce, + so it has to show up on the work order even though the final operation loses nothing.""" + 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("Intermediate Loss RM 1", {"is_stock_item": 1}).name + sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name + fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + + operations = [ + { + "operation": "Intermediate Loss Op A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "sequence_id": 1, + }, + { + "operation": "Intermediate Loss Op B", + "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": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=10, + 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=rm, target=warehouse, qty=100, 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", + ), + ) + + jc_a = get_job_card("Intermediate Loss Op A") + jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"}) + jc_a.save() + jc_a.complete_job_card( + qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00" + ) + jc_a.reload() + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.process_loss_qty), 2) + + # Operation A handed over only 8 units, so the final operation works on 8. + jc_b = get_job_card("Intermediate Loss Op B") + jc_b.for_quantity = 8 + for row in jc_b.items: + row.required_qty = 8 + jc_b.append( + "time_logs", + {"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8}, + ) + jc_b.save() + jc_b.submit() + frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 8) + self.assertEqual(flt(work_order.process_loss_qty), 2) + self.assertEqual(work_order.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) + +>>>>>>> 24de81f9fa (test: work order process loss for semi finished goods) 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 a22a7fddba07916f796012136ae64db2781c65e1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:29 +0530 Subject: [PATCH 29/43] 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 30/43] 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 31/43] 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 32/43] 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 33/43] 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 34/43] 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 35/43] 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 36/43] 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 37/43] 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 38/43] 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 a5e4bbd4367d8d2472bfc4826430fcea8f98f2fb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:07:17 +0530 Subject: [PATCH 39/43] chore: resolve conflict --- erpnext/manufacturing/doctype/bom/bom.py | 36 +- .../doctype/job_card/job_card.py | 157 +----- .../doctype/job_card/test_job_card.py | 393 ++------------- .../doctype/work_order/services/status.py | 471 ------------------ .../doctype/work_order/test_work_order.py | 57 --- .../doctype/work_order/work_order.py | 8 +- .../stock/doctype/stock_entry/stock_entry.py | 20 +- .../stock_entry_type/stock_entry_type.py | 5 + 8 files changed, 66 insertions(+), 1081 deletions(-) delete mode 100644 erpnext/manufacturing/doctype/work_order/services/status.py diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index e33e81d5aaa..00e754ff561 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -305,10 +305,9 @@ class BOM(WebsiteGenerator): self.set_fg_cost_allocation() self.validate_total_cost_allocation() -<<<<<<< HEAD if self.docstatus == 1: self.validate_raw_materials_of_operation() -======= + def set_operation_finished_goods(self): """Fill each operation's FG item where it is unambiguous: the final operation produces this BOM's item, an operation with a BOM produces that BOM's item. Runs before @@ -321,7 +320,6 @@ class BOM(WebsiteGenerator): row.finished_good = self.item elif row.bom_no and not row.finished_good: row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") ->>>>>>> 1e2e87daac (fix: derive operation FG items before material expansion, keep the final one the BOM's item) def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: @@ -866,17 +864,10 @@ class BOM(WebsiteGenerator): row.update(get_item_details(row.get("item_code"))) row.operation_row_id = operation_row_id - item_row = None - if row.name: - item_row = self.get_item_data(row.name) + item_row = self.get_item_data(row.item_code, operation_row_id) if item_row: - item_row.update( - { - "item_code": row.get("item_code"), - "qty": row.get("qty"), - } - ) + item_row.qty = row.get("qty") else: row.idx = None row.name = None @@ -887,27 +878,6 @@ class BOM(WebsiteGenerator): self.save() -<<<<<<< HEAD -======= - def _add_raw_material_row(self, operation_row_id, row): - row = parse_json(row) - - row.update(get_item_details(row.get("item_code"))) - row.operation_row_id = operation_row_id - - item_row = self.get_item_data(row.item_code, operation_row_id) - - if item_row: - item_row.qty = row.get("qty") - else: - row.idx = None - row.name = None - row.do_not_explode = 1 - row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code) - - self.append("items", row) - ->>>>>>> 24f1f3dea8 (fix: add raw material to its operation even when another operation uses the item) def is_sub_assembly_item(self, item_code): if not self.operations: return False diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 970f81d5e03..9486ed14094 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1041,32 +1041,7 @@ class JobCard(Document): ) def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): -<<<<<<< HEAD workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") -======= - time_data = self.get_operation_time_data() - - for data in wo.operations: - if data.get("name") == self.operation_id: - self.update_wo_operation_row( - data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data - ) - - wo.flags.ignore_validate_update_after_submit = True - wo.update_operation_status() - wo.calculate_operating_cost() - wo.set_actual_dates() - - if wo.track_semi_finished_goods: - wo.set_process_loss_qty() - - if time_data: - wo.status = "In Process" - - wo.save() - - def get_operation_time_data(self): ->>>>>>> 0eb61c9fac (fix: roll up process loss to the work order for semi finished goods) jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") @@ -1101,6 +1076,9 @@ class JobCard(Document): wo.calculate_operating_cost() wo.set_actual_dates() + if wo.track_semi_finished_goods: + wo.set_process_loss_qty() + if time_data: wo.status = "In Process" @@ -1367,57 +1345,6 @@ class JobCard(Document): if not (self.work_order and self.sequence_id): return -<<<<<<< HEAD -======= - current_operation_qty = self.get_current_operation_completed_qty() - - 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( - "Work Order Operation", - fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"], - filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, - order_by="sequence_id, idx", - ) - - if self.track_semi_finished_goods and previous_operations: - totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations]) - - for row in previous_operations: - operation_totals = totals.get(row.name) - row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty) - row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty) - - 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).as_("manufactured_qty"), - Sum(job_card.process_loss_qty).as_("process_loss_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(as_dict=True) - - return {row.operation_id: row for row in data} - - def get_current_operation_completed_qty(self): ->>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) current_operation_qty = 0.0 data = self.get_current_operation_data() if data and len(data) > 0: @@ -1453,7 +1380,6 @@ class JobCard(Document): OperationSequenceError, ) -<<<<<<< HEAD if row.completed_qty < current_operation_qty: frappe.throw( _( @@ -1465,49 +1391,6 @@ class JobCard(Document): bold(row.operation), ) ) -======= - 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: - return - - if manufactured_qty + flt(row.process_loss_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}, as {4} was booked as process loss there." - ).format( - bold(self.get_qty_with_uom(current_operation_qty)), - bold(self.operation), - bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), - bold(row.operation), - bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)), - ), - OperationSequenceError, - ) ->>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) - - 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(self.get_qty_with_uom(current_operation_qty)), - bold(self.operation), - bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), - bold(row.operation), - ), - OperationSequenceError, - ) def validate_work_order(self): if self.is_work_order_closed(): @@ -1684,33 +1567,27 @@ class JobCard(Document): _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)) ) + def get_consumed_process_loss(self): + table = frappe.qb.DocType("Stock Entry") + query = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)) + ) + return query.run()[0][0] or 0 + @frappe.whitelist() def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False): - def get_consumed_process_loss(): - table = frappe.qb.DocType("Stock Entry") - query = ( - frappe.qb.from_(table) - .select(Sum(table.process_loss_qty)) - .where( - (table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1) - ) - ) - return query.run()[0][0] or 0 - from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry -<<<<<<< HEAD + consumed_process_loss = self.get_consumed_process_loss() ste = ManufactureEntry( { - "for_quantity": self.for_quantity - self.manufactured_qty, - "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), -======= - consumed_process_loss = self.get_consumed_process_loss() - return ManufactureEntry( - { - "for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss, + "for_quantity": self.for_quantity + - self.pending_qty + - self.manufactured_qty + - consumed_process_loss, "process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0), ->>>>>>> b8dd886cd4 (fix: generate the next manufacture entry net of booked process loss) "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 c1f48953e5b..9fa4b99e6be 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,89 +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 - - 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_process_loss_rolls_up_to_work_order(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item @@ -1400,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-05-01 08:00:00"}) job_card.save() @@ -1528,7 +1454,6 @@ class TestJobCard(ERPNextTestSuite): work_order.reload() self.assertEqual(flt(work_order.process_loss_qty), 2) - # Operation A handed over only 8 units, so the final operation works on 8. jc_b = get_job_card("Intermediate Loss Op B") jc_b.for_quantity = 8 for row in jc_b.items: @@ -1546,145 +1471,6 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(work_order.process_loss_qty), 2) self.assertEqual(work_order.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) - ->>>>>>> 24de81f9fa (test: work order process loss for semi finished goods) 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 @@ -2530,6 +2316,27 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(s.additional_costs[2].amount, 480) self.assertEqual(s.additional_costs[3].amount, 480) + def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): + jc = frappe.new_doc("Job Card") + jc.track_semi_finished_goods = 1 + jc.skip_material_transfer = 1 + jc.for_quantity = 10 + jc.transferred_qty = 0 + jc.append("items", {"item_code": "_Test Item"}) + + jc.validate_transfer_qty() + + # with transfer enabled, a legacy card without an FG item keeps the strict check + jc.skip_material_transfer = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) + + jc.finished_good = "_Test Item" + jc.validate_transfer_qty() + + jc.finished_good = None + jc.track_semi_finished_goods = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" @@ -2592,141 +2399,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_qty_in_messages_carries_the_uom(self): - jc = frappe.new_doc("Job Card") - jc.stock_uom = "Nos" - - self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos") - self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos") - - 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)) - - def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self): - jc = frappe.new_doc("Job Card") - jc.operation = "_Test Painting" - jc.stock_uom = "Nos" - row = frappe._dict( - operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None - ) - - with self.assertRaises(OperationSequenceError) as loss_error: - jc.validate_previous_operation_manufactured_qty(row, 10) - self.assertIn("process loss", str(loss_error.exception)) - - row.process_loss_qty = 0 - with self.assertRaises(OperationSequenceError) as pending_error: - jc.validate_previous_operation_manufactured_qty(row, 10) - self.assertIn("Submit the manufacturing entry", str(pending_error.exception)) - - jc.validate_previous_operation_manufactured_qty(row, 8) - - def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): - jc = frappe.new_doc("Job Card") - jc.track_semi_finished_goods = 1 - jc.skip_material_transfer = 1 - jc.for_quantity = 10 - jc.transferred_qty = 0 - jc.append("items", {"item_code": "_Test Item"}) - - jc.validate_transfer_qty() - - # with transfer enabled, a legacy card without an FG item keeps the strict check - jc.skip_material_transfer = 0 - self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) - - jc.finished_good = "_Test Item" - jc.validate_transfer_qty() - - jc.finished_good = None - jc.track_semi_finished_goods = 0 - self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) ->>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py deleted file mode 100644 index 74f204acd40..00000000000 --- a/erpnext/manufacturing/doctype/work_order/services/status.py +++ /dev/null @@ -1,471 +0,0 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Status and quantity-rollup logic for Work Order. - -Extracted from work_order.py. ``StatusService`` wraps a Work Order document -(composition); work_order.py keeps thin delegating stubs so the many external -callers (job cards, sales orders, production plans, patches) keep working. -""" - -import frappe -from frappe import _ -from frappe.query_builder.functions import Sum -from frappe.utils import cint, flt, get_link_to_form - -from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty - -_QTY_PURPOSES = ( - ("Manufacture", "produced_qty"), - ("Material Transfer for Manufacture", "material_transferred_for_manufacturing"), - ("Material Transfer for Manufacture", "additional_transferred_qty"), -) - - -class StatusService: - def __init__(self, doc): - self.doc = doc - - def validate_work_order_against_so(self): - from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError - - total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty) - so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty()) - allowance_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order") - ) - if total_qty <= so_qty + (allowance_percentage / 100 * so_qty): - return - - frappe.throw( - _("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format( - get_link_to_form("Item", self.doc.production_item), - frappe.bold(so_qty), - frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")), - ), - OverProductionError, - ) - - def _ordered_qty_against_so(self): - wo = frappe.qb.DocType("Work Order") - return ( - frappe.qb.from_(wo) - .select(Sum(wo.qty - wo.process_loss_qty)) - .where( - (wo.production_item == self.doc.production_item) - & (wo.sales_order == self.doc.sales_order) - & (wo.docstatus == 1) - & (wo.status != "Closed") - & (wo.name != self.doc.name) - ) - ).run()[0][0] - - def _so_item_qty(self): - so_item = frappe.qb.DocType("Sales Order Item") - return ( - frappe.qb.from_(so_item) - .select(Sum(so_item.stock_qty)) - .where( - (so_item.parent == self.doc.sales_order) - & (so_item.item_code == self.doc.production_item) - & (so_item.docstatus == 1) - ) - ).run()[0][0] - - def _packed_item_qty(self): - packed_item = frappe.qb.DocType("Packed Item") - return ( - frappe.qb.from_(packed_item) - .select(Sum(packed_item.qty)) - .where( - (packed_item.parent == self.doc.sales_order) - & (packed_item.parenttype == "Sales Order") - & (packed_item.item_code == self.doc.production_item) - & (packed_item.docstatus == 1) - ) - ).run()[0][0] - - def update_status(self, status=None): - """Update status of work order if unknown""" - if self.doc.docstatus == 1: - # Refresh material_transferred_for_manufacturing before deciding status so pick-list- - # driven transfers (where this qty is derived from item transfers, not fg_completed_qty) - # are reflected immediately, instead of only after the next status update call. - self.doc.refresh_material_transferred_for_manufacturing() - - if self.doc.status != "Closed": - if status not in ["Stopped", "Closed"]: - status = self.get_status(status) - - if status != self.doc.status: - self.doc.db_set("status", status) - - self.doc.update_required_items() - - return status or self.doc.status - - def get_status(self, status=None): - """Return the status based on stock entries against this work order""" - status = status or self.doc.status - - if self.doc.docstatus == 0: - status = "Draft" - elif self.doc.docstatus == 1: - status = self._submitted_status(status) - else: - status = "Cancelled" - - if self._is_partial_skip_transfer(): - status = "In Process" - - if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations): - status = "In Process" - - if status == "Not Started" and self.doc.reserve_stock: - status = self._reservation_status(status) - - return status - - def _submitted_status(self, status): - if status in ["Closed", "Stopped"]: - return status - - status = ( - "In Process" - if flt(self.doc.material_transferred_for_manufacturing) > 0 - or self.doc.skip_transfer - or self._has_transferred_material() - else "Not Started" - ) - precision = frappe.get_precision("Work Order", "produced_qty") - total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision) - if flt(total_qty, precision) >= flt(self.doc.qty, precision): - status = "Completed" - return status - - def _has_transferred_material(self): - """True if any raw material was transferred against this work order via a pick list - or a material request (these leave material_transferred_for_manufacturing at 0 via - the min-fraction rule).""" - ste = frappe.qb.DocType("Stock Entry") - ste_child = frappe.qb.DocType("Stock Entry Detail") - mr_child = frappe.qb.DocType("Stock Entry Detail") - # Stock Entry only carries `material_request` at the child-row level, so a Stock - # Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once - # that's established, sum every row's transfer_qty, not just the linked ones (a - # manually appended extra row on the same entry has no material_request of its own). - mr_sourced_stock_entries = ( - frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull()) - ) - qty = ( - frappe.qb.from_(ste) - .inner_join(ste_child) - .on(ste_child.parent == ste.name) - .select(Sum(ste_child.transfer_qty)) - .where( - (ste.work_order == self.doc.name) - & (ste.docstatus == 1) - & (ste.purpose == "Material Transfer for Manufacture") - & (ste.is_return == 0) - & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) - ) - ).run()[0][0] - return flt(qty) > 0 - - def _is_partial_skip_transfer(self): - return bool( - self.doc.skip_transfer - and self.doc.produced_qty - and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty)) - ) - - def _reservation_status(self, status): - for row in self.doc.required_items: - if not row.stock_reserved_qty: - continue - - if row.stock_reserved_qty >= row.required_qty: - status = "Stock Reserved" - else: - return "Stock Partially Reserved" - return status - - def update_work_order_qty(self): - """Update Manufactured Qty and Material Transferred for Qty based on Stock Entry""" - if self.doc.track_semi_finished_goods: - return - - for purpose, fieldname in _QTY_PURPOSES: - self._update_qty_for_purpose(purpose, fieldname) - - if self.doc.production_plan: - self.set_produced_qty_for_sub_assembly_item() - self.update_production_plan_status() - - if self.doc.additional_transferred_qty: - self.doc.validate_additional_transferred_qty() - - def _update_qty_for_purpose(self, purpose, fieldname): - from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError - - if self._skip_transfer_purpose(purpose): - return - - qty = self.get_transferred_or_manufactured_qty(purpose, fieldname) - completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty) - if qty > completed_qty: - frappe.throw( - _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format( - _(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name - ), - StockOverProductionError, - ) - - self.doc.db_set(fieldname, qty) - self.set_process_loss_qty() - self._update_produced_qty_in_so() - - def _skip_transfer_purpose(self, purpose): - return bool( - purpose == "Material Transfer for Manufacture" - and self.doc.operations - and self.doc.transfer_material_against == "Job Card" - ) - - def _qty_allowance(self, purpose): - allowance = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") - ) - if not allowance and purpose == "Material Transfer for Manufacture": - allowance = flt( - frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") - ) - return allowance - - def _update_produced_qty_in_so(self): - from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item - - if ( - self.doc.sales_order - and self.doc.sales_order_item - and not self.doc.production_plan_sub_assembly_item - ): - update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item) - - def update_disassembled_qty(self, qty, is_cancel=False): - if is_cancel: - self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty) - else: - if self.doc.docstatus == 1: - self.doc.disassembled_qty += qty - - if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty: - frappe.throw(_("Cannot disassemble more than produced quantity.")) - - self.doc.db_set("disassembled_qty", self.doc.disassembled_qty) - - def get_transferred_or_manufactured_qty(self, purpose, fieldname): - parent = frappe.qb.DocType("Stock Entry") - is_additional = cint(fieldname == "additional_transferred_qty") - query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional)) - - if purpose == "Manufacture": - child = frappe.qb.DocType("Stock Entry Detail") - query = ( - query.join(child) - .on(parent.name == child.parent) - .select(Sum(child.transfer_qty)) - .where(child.is_finished_item == 1) - ) - else: - query = query.select(Sum(parent.fg_completed_qty)) - - return flt(query.run()[0][0]) - - def _stock_entry_filter(self, parent, purpose, is_additional): - return ( - (parent.work_order == self.doc.name) - & (parent.docstatus == 1) - & (parent.purpose == purpose) - & (parent.is_additional_transfer_entry == is_additional) - ) - - def set_process_loss_qty(self): - self.doc.db_set("process_loss_qty", self._process_loss_qty()) - - def _process_loss_qty(self): - if self.doc.track_semi_finished_goods: - return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations)) - - table = frappe.qb.DocType("Stock Entry") - process_loss_qty = ( - frappe.qb.from_(table) - .select(Sum(table.process_loss_qty)) - .where( - (table.work_order == self.doc.name) - & (table.purpose == "Manufacture") - & (table.docstatus == 1) - ) - ).run()[0][0] - - return flt(process_loss_qty) - - def update_production_plan_status(self): - production_plan = frappe.get_doc("Production Plan", self.doc.production_plan) - produced_qty = 0 - if self.doc.production_plan_item: - total_qty = frappe.get_all( - "Work Order", - fields=[{"SUM": "produced_qty", "as": "produced_qty"}], - filters={ - "docstatus": 1, - "production_plan": self.doc.production_plan, - "production_plan_item": self.doc.production_plan_item, - }, - as_list=1, - ) - - produced_qty = total_qty[0][0] if total_qty else 0 - - self.update_status() - production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item) - - def update_planned_qty(self): - if self.doc.track_semi_finished_goods: - return - - update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict()) - - if self.doc.material_request: - mr_obj = frappe.get_doc("Material Request", self.doc.material_request) - mr_obj.update_requested_qty([self.doc.material_request_item]) - - def _planned_qty_dict(self): - from erpnext.manufacturing.doctype.production_plan.production_plan import ( - get_reserved_qty_for_sub_assembly, - ) - - qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)} - if self.doc.production_plan_sub_assembly_item and self.doc.production_plan: - qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly( - self.doc.production_item, self.doc.fg_warehouse - ) - return qty_dict - - def set_produced_qty_for_sub_assembly_item(self): - produced_qty = self._sub_assembly_produced_qty() - frappe.db.set_value( - "Production Plan Sub Assembly Item", - self.doc.production_plan_sub_assembly_item, - "wo_produced_qty", - produced_qty, - ) - - def _sub_assembly_produced_qty(self): - table = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(table) - .select(Sum(table.produced_qty)) - .where( - (table.production_plan == self.doc.production_plan) - & (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item) - & (table.docstatus == 1) - ) - ).run() - return flt(query[0][0]) if query else 0 - - def update_ordered_qty(self): - if not ( - self.doc.production_plan - and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item) - ): - return - - qty = self._production_plan_ordered_qty() - if self.doc.production_plan_item: - frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty) - elif self.doc.production_plan_sub_assembly_item: - field = self.doc.production_plan_sub_assembly_item - frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty) - - doc = frappe.get_doc("Production Plan", self.doc.production_plan) - doc.set_status() - doc.db_set("status", doc.status) - - def _production_plan_ordered_qty(self): - table = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty)) - .where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1)) - ) - if self.doc.production_plan_item: - query = query.where(table.production_plan_item == self.doc.production_plan_item) - elif self.doc.production_plan_sub_assembly_item: - query = query.where( - table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item - ) - - result = query.run() - return flt(result[0][0]) if result else 0 - - def update_work_order_qty_in_so(self): - if ( - not self.doc.sales_order and not self.doc.sales_order_item - ) or self.doc.production_plan_sub_assembly_item: - return - - total_bundle_qty = self._total_bundle_qty() - work_order_qty = self._sales_order_work_order_qty() - frappe.db.set_value( - "Sales Order Item", - self.doc.sales_order_item, - "work_order_qty", - flt(work_order_qty / total_bundle_qty, 2), - ) - - def _sales_order_work_order_qty(self): - wo = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(wo) - .select(Sum(wo.qty)) - .where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed")) - ) - if self.doc.product_bundle_item: - query = query.where(wo.product_bundle_item == self.doc.product_bundle_item) - else: - query = query.where(wo.production_item == self.doc.production_item) - - qty = query.run(as_list=1) - return qty[0][0] if qty and qty[0][0] else 0 - - def update_work_order_qty_in_combined_so(self): - total_bundle_qty = self._total_bundle_qty() - prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan) - item_reference = frappe.get_value( - "Production Plan Item", self.doc.production_plan_item, "sales_order_item" - ) - - for plan_reference in prod_plan.prod_plan_references: - if plan_reference.item_reference != item_reference: - continue - - qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0 - frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty) - - def _total_bundle_qty(self): - if not self.doc.product_bundle_item: - return 1 - - pbi = frappe.qb.DocType("Product Bundle Item") - total_bundle_qty = ( - frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item) - ).run()[0][0] - # product bundle is 0 (product bundle allows 0 qty for items) - return total_bundle_qty or 1 - - def update_completed_qty_in_material_request(self): - if self.doc.material_request and self.doc.material_request_item: - frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty( - [self.doc.material_request_item] - ) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 3e5304180bf..18bd6ff7998 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4839,59 +4839,6 @@ class TestWorkOrder(ERPNextTestSuite): # generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units) self.assertEqual(flt(row.qty, 6), 3.0) -<<<<<<< HEAD -======= - def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self): - """When one item is transferred both for itself and as a substitute for another required item, - each transfer must be credited to the right required item. - - _material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked - Max(original_item); for item B transferred once for itself (original_item NULL) and once as a - substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving - B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes - each transfer correctly, deterministically on MariaDB and Postgres. - """ - from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService - - source_warehouse = "Stores - _TC" - fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name - item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name - item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name - - # B is a registered alternative for A - if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}): - frappe.get_doc( - { - "doctype": "Item Alternative", - "item_code": item_a, - "alternative_item_code": item_b, - "two_way": 1, - } - ).insert() - - # stock B generously (covers B-for-A plus B-for-itself) - for item, qty in ((item_a, 50), (item_b, 100)): - test_stock_entry.make_stock_entry( - item_code=item, target=source_warehouse, qty=qty, basic_rate=100 - ) - - make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b]) - wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) - - transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) - transfer.save() - # substitute B for the A line; the existing B line stays as B's own transfer - for d in transfer.items: - if d.item_code == item_a: - d.item_code = item_b - d.original_item = item_a - transfer.submit() - - qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0) - # B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B. - self.assertEqual(flt(qty_by_item.get(item_a)), 10.0) - self.assertEqual(flt(qty_by_item.get(item_b)), 10.0) - def test_wip_warehouse_required_when_tracking_semi_finished_goods(self): wo = frappe.new_doc("Work Order") wo.track_semi_finished_goods = 1 @@ -4903,9 +4850,6 @@ class TestWorkOrder(ERPNextTestSuite): wo.wip_warehouse = "_Test Warehouse - _TC" wo.validate_warehouse() -<<<<<<< HEAD ->>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) -======= # the top-level target warehouse stays optional; operations may carry their own wo.fg_warehouse = None wo.validate_warehouse() @@ -4913,7 +4857,6 @@ class TestWorkOrder(ERPNextTestSuite): wo.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, wo.validate_warehouse) ->>>>>>> db99657c47 (test: target warehouse stays optional for semi FG work orders) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 30ed33a66a4..bb3d256ffee 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -883,6 +883,12 @@ class WorkOrder(Document): return flt(query.run()[0][0]) def set_process_loss_qty(self): + self.db_set("process_loss_qty", self._process_loss_qty()) + + def _process_loss_qty(self): + if self.track_semi_finished_goods: + return flt(sum(flt(row.process_loss_qty) for row in self.operations)) + table = frappe.qb.DocType("Stock Entry") process_loss_qty = ( frappe.qb.from_(table) @@ -892,7 +898,7 @@ class WorkOrder(Document): ) ).run()[0][0] - self.db_set("process_loss_qty", flt(process_loss_qty)) + return flt(process_loss_qty) def update_production_plan_status(self): production_plan = frappe.get_doc("Production Plan", self.production_plan) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 36da536c328..0c6c3bbee9b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3197,23 +3197,8 @@ class StockEntry(StockController, SubcontractingInwardController): if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): self.process_loss_qty = flt(process_loss_qty, precision) - frappe.msgprint( - _("The Process Loss Qty has been reset as per the job card's Process Loss Qty"), - alert=True, - ) + frappe.msgprint(_("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True) -<<<<<<< HEAD - if data and data[0].process_loss_qty: - process_loss_qty = data[0].process_loss_qty - if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): - self.process_loss_qty = flt(process_loss_qty, precision) - - frappe.msgprint( - _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True - ) - -======= ->>>>>>> 1b335973b7 (fix: scope manufacture entry process loss to its own job card) if not self.process_loss_percentage and not self.process_loss_qty: self.process_loss_percentage = frappe.get_cached_value( "BOM", self.bom_no, "process_loss_percentage" @@ -3243,7 +3228,8 @@ class StockEntry(StockController, SubcontractingInwardController): precision = frappe.get_precision("Stock Entry Detail", "qty") pending_qty = flt( - flt(job_card.get_qty_to_produce()) + flt(job_card.for_quantity) + - flt(job_card.pending_qty) - flt(job_card.manufactured_qty) - flt(job_card.get_consumed_process_loss()), precision, diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index 74996b96a22..39e77f0929d 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -126,6 +126,7 @@ class ManufactureEntry: available_serial_batches = self.get_transferred_serial_batches() production_share = self.get_production_share() + items_to_remove = [] for item_code, _dict in item_dict.items(): _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.to_warehouse = "" @@ -143,11 +144,15 @@ class ManufactureEntry: remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0) _dict.qty = min(flt(_dict.qty) * production_share, remaining_qty) if not _dict.qty: + items_to_remove.append(item_code) continue if self.skip_material_transfer: set_previous_operation_serial_batch(self.stock_entry, _dict) + for item_code in items_to_remove: + item_dict.pop(item_code) + self.stock_entry.add_to_stock_entry_detail(item_dict) def get_production_share(self): From beed05ac1844c0d775891ded7988b1e87287702d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:08:57 +0530 Subject: [PATCH 40/43] 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 87b456faa270df150eca7be9a729b844bfc4b7c9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:10:08 +0530 Subject: [PATCH 41/43] fix(manufacturing): type whitelisted BOM arguments --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 00e754ff561..a706f5daa13 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -854,7 +854,7 @@ class BOM(WebsiteGenerator): self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) @frappe.whitelist() - def add_raw_materials(self, operation_row_id, items): + def add_raw_materials(self, operation_row_id: str, items: str | list[dict]) -> None: if isinstance(items, str): items = parse_json(items) From a8060d5e996e1698ac6bdb2e872a70b079ef67aa Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:22:17 +0530 Subject: [PATCH 42/43] fix(manufacturing): adapt version 16 compatibility --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index a706f5daa13..06f7ff894d6 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -854,7 +854,7 @@ class BOM(WebsiteGenerator): self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) @frappe.whitelist() - def add_raw_materials(self, operation_row_id: str, items: str | list[dict]) -> None: + def add_raw_materials(self, operation_row_id: str | int, items: str | list[dict]) -> None: if isinstance(items, str): items = parse_json(items) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0c6c3bbee9b..cd442909b1c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3238,7 +3238,8 @@ class StockEntry(StockController, SubcontractingInwardController): entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision) if entry_qty > pending_qty: - uom = job_card.stock_uom + item_code = job_card.finished_good or job_card.production_item + uom = frappe.get_cached_value("Item", item_code, "stock_uom") frappe.throw( _( "The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first." From 317dd18ce57ec5a07b0c6560e1ecd6f724013b23 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:43:00 +0530 Subject: [PATCH 43/43] 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"