From e5a0b5f61ed76d64f40b5f42f075b051e6a0198a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 12 Aug 2026 15:43:29 +0530 Subject: [PATCH] fix(manufacturing): refine corrective job cards (#58079) --- .../doctype/job_card/job_card.js | 48 +++++++++- .../doctype/job_card/job_card.json | 5 +- .../doctype/job_card/job_card.py | 19 ++-- .../manufacturing/doctype/job_card/mapper.py | 19 +++- .../doctype/job_card/test_job_card.py | 96 +++++++++++++++++++ .../doctype/work_order/work_order.js | 28 ++++-- 6 files changed, 196 insertions(+), 19 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 130eda01d6b..d0190ae69ee 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -96,6 +96,41 @@ frappe.ui.form.on("Job Card", { } }, + set_corrective_job_card_labels(frm) { + const is_corrective_job_card = Boolean(frm.doc.is_corrective_job_card); + + frm.set_df_property( + "for_quantity", + "label", + is_corrective_job_card ? __("Qty To Correct") : __("Qty To Manufacture") + ); + frm.set_df_property( + "total_completed_qty", + "label", + is_corrective_job_card ? __("Total Corrected Qty") : __("Total Completed Qty") + ); + }, + + toggle_material_tables(frm) { + const backflush_based_on_material_transfer = + frm.doc.__onload?.backflush_raw_materials_based_on === "Material Transferred for Manufacture"; + const show_material_tables = + backflush_based_on_material_transfer || + frm.doc.__onload?.transfer_material_against === "Job Card" || + Boolean(frm.doc.track_semi_finished_goods) || + Boolean(frm.doc.items?.length) || + Boolean(frm.doc.secondary_items?.length); + + frm.toggle_display( + ["section_break_8", "items", "secondary_items_section", "secondary_items"], + show_material_tables + ); + }, + + track_semi_finished_goods(frm) { + frm.trigger("toggle_material_tables"); + }, + setup_stock_entry(frm) { const { doc } = frm; const can_make_stock_entry = @@ -140,6 +175,8 @@ frappe.ui.form.on("Job Card", { } frm.trigger("make_fields_read_only"); + frm.trigger("set_corrective_job_card_labels"); + frm.trigger("toggle_material_tables"); if (!frm.is_new() && doc.__onload?.work_order_closed) { frm.disable_save(); @@ -464,6 +501,7 @@ frappe.ui.form.on("Job Card", { label: __("Corrective Operation"), options: "Operation", fieldname: "operation", + reqd: 1, get_query() { return { filters: { is_corrective_operation: 1 } }; }, @@ -473,6 +511,7 @@ frappe.ui.form.on("Job Card", { label: __("For Operation"), options: "Operation", fieldname: "for_operation", + reqd: 1, get_query() { return { filters: { name: ["in", operations] } }; }, @@ -482,10 +521,11 @@ frappe.ui.form.on("Job Card", { frappe.prompt( fields, (d) => frm.events.make_corrective_job_card(frm, d.operation, d.for_operation), - __("Select Corrective Operation") + __("Select Corrective Operation"), + __("Create") ); }, - __("Make") + __("Create") ); }, @@ -816,6 +856,10 @@ frappe.ui.form.on("Job Card", { }, for_quantity(frm) { + if (frm.doc.is_corrective_job_card) { + return; + } + frm.doc.items = []; frm.call({ method: "get_required_items", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 86478b7c290..bfd5b1e147c 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -252,6 +252,7 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_corrective_job_card", "fieldname": "requested_qty", "fieldtype": "Float", "label": "Requested Qty", @@ -552,6 +553,7 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_corrective_job_card", "fieldname": "is_subcontracted", "fieldtype": "Check", "label": " Is Subcontracted", @@ -604,6 +606,7 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_corrective_job_card", "fetch_from": "work_order.track_semi_finished_goods", "fieldname": "track_semi_finished_goods", "fieldtype": "Check", @@ -697,7 +700,7 @@ "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-08-01 14:22:19.926911", + "modified": "2026-08-12 15:28:19.126628", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index bc52de3f836..58f8f2fe501 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -27,7 +27,11 @@ from erpnext.controllers.stock_controller import ( QualityInspectionNotSubmittedError, QualityInspectionRejectedError, ) -from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_items_as_dict +from erpnext.manufacturing.doctype.bom.bom import ( + add_additional_cost, + get_backflush_based_on, + get_bom_items_as_dict, +) from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( get_mins_between_operations, ) @@ -148,6 +152,11 @@ class JobCard(Document): def onload(self): excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") self.set_onload("job_card_excess_transfer", excess_transfer) + self.set_onload("backflush_raw_materials_based_on", get_backflush_based_on(self.bom_no)) + self.set_onload( + "transfer_material_against", + frappe.get_cached_value("Work Order", self.work_order, "transfer_material_against"), + ) self.set_onload("work_order_closed", self.is_work_order_closed()) self.set_onload("has_stock_entry", self.has_stock_entry()) @@ -786,7 +795,7 @@ class JobCard(Document): def get_required_items(self): frappe.has_permission("Job Card", "write", doc=self, throw=True) - if not self.get("work_order"): + if self.is_corrective_job_card or not self.get("work_order"): return doc = frappe.get_doc("Work Order", self.get("work_order")) @@ -806,11 +815,7 @@ class JobCard(Document): ) ) - if not ( - self.get("operation") == d.operation - or self.operation_row_id == d.operation_row_id - or self.is_corrective_job_card - ): + if not (self.get("operation") == d.operation or self.operation_row_id == d.operation_row_id): return self.append( diff --git a/erpnext/manufacturing/doctype/job_card/mapper.py b/erpnext/manufacturing/doctype/job_card/mapper.py index 0c1da88baa2..42cf4c91e5b 100644 --- a/erpnext/manufacturing/doctype/job_card/mapper.py +++ b/erpnext/manufacturing/doctype/job_card/mapper.py @@ -7,6 +7,7 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.utils import flt +from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, ) @@ -164,17 +165,33 @@ def make_corrective_job_card( for_operation: str | None = None, target_doc: str | dict | Document | None = None, ): + if not operation: + frappe.throw(_("Corrective Operation is required")) + + if not for_operation: + frappe.throw(_("For Operation is required")) + def set_missing_values(source, target): + if source.track_semi_finished_goods: + frappe.throw( + _("Corrective Job Cards cannot be created for Work Orders that track semi-finished goods") + ) + target.is_corrective_job_card = 1 target.operation = operation target.for_operation = for_operation + target.total_completed_qty = 0 target.set("time_logs", []) target.set("employee", []) target.set("items", []) target.set("sub_operations", []) target.set_sub_operations() - target.get_required_items() + target.set_onload("backflush_raw_materials_based_on", get_backflush_based_on(target.bom_no)) + target.set_onload( + "transfer_material_against", + frappe.get_cached_value("Work Order", target.work_order, "transfer_material_against"), + ) doclist = get_mapped_doc( "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 67c70b7b9f4..215ff152389 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -582,6 +582,102 @@ class TestJobCard(ERPNextTestSuite): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty)) + def test_corrective_job_card_requires_operation_details(self): + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + + with self.assertRaisesRegex(frappe.ValidationError, "Corrective Operation is required"): + make_corrective_job_card(job_card.name, for_operation=job_card.operation) + + with self.assertRaisesRegex(frappe.ValidationError, "For Operation is required"): + make_corrective_job_card(job_card.name, operation=job_card.operation) + + def test_corrective_job_card_not_allowed_for_tracked_semi_finished_goods(self): + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + job_card.db_set("track_semi_finished_goods", 1) + + with self.assertRaisesRegex(frappe.ValidationError, "track semi-finished goods"): + make_corrective_job_card( + job_card.name, + operation=job_card.operation, + for_operation=job_card.operation, + ) + + def test_corrective_job_card_does_not_copy_total_completed_qty(self): + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + job_card.append( + "time_logs", + {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 1}, + ) + job_card.save() + self.assertEqual(job_card.total_completed_qty, 1) + + corrective_job_card = make_corrective_job_card( + job_card.name, + operation=job_card.operation, + for_operation=job_card.operation, + ) + + self.assertEqual(corrective_job_card.total_completed_qty, 0) + + @ERPNextTestSuite.change_settings( + "Manufacturing Settings", {"backflush_raw_materials_based_on": "Material Transferred for Manufacture"} + ) + def test_corrective_job_card_does_not_autofill_items(self): + self.transfer_material_against = "Job Card" + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + frappe.db.set_value("BOM", job_card.bom_no, "backflush_based_on", "") + self.assertTrue(job_card.items) + + corrective_job_card = make_corrective_job_card( + job_card.name, + operation=job_card.operation, + for_operation=job_card.operation, + ) + + self.assertFalse(corrective_job_card.items) + corrective_job_card.get_required_items() + self.assertFalse(corrective_job_card.items) + self.assertEqual( + corrective_job_card.get_onload("backflush_raw_materials_based_on"), + "Material Transferred for Manufacture", + ) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"}) + def test_corrective_job_card_uses_bom_backflush_setting(self): + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + frappe.db.set_value( + "BOM", + job_card.bom_no, + "backflush_based_on", + "Material Transferred for Manufacture", + ) + + corrective_job_card = make_corrective_job_card( + job_card.name, + operation=job_card.operation, + for_operation=job_card.operation, + ) + + self.assertEqual( + corrective_job_card.get_onload("backflush_raw_materials_based_on"), + "Material Transferred for Manufacture", + ) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"}) + def test_corrective_job_card_uses_work_order_transfer_setting(self): + self.transfer_material_against = "Job Card" + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + frappe.db.set_value("BOM", job_card.bom_no, "backflush_based_on", "BOM") + + corrective_job_card = make_corrective_job_card( + job_card.name, + operation=job_card.operation, + for_operation=job_card.operation, + ) + + self.assertEqual(corrective_job_card.get_onload("backflush_raw_materials_based_on"), "BOM") + self.assertEqual(corrective_job_card.get_onload("transfer_material_against"), "Job Card") + @ERPNextTestSuite.change_settings( "Manufacturing Settings", { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 079b2831e78..359073f9821 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -196,9 +196,13 @@ frappe.ui.form.on("Work Order", { frm.doc.operations.length ) { if (frm.doc.__onload?.show_create_job_card_button) { - frm.add_custom_button(__("Create Job Card"), () => { - frm.trigger("make_job_card"); - }); + frm.add_custom_button( + __("Create Job Card"), + () => { + frm.trigger("make_job_card"); + }, + __("Create") + ); } } } @@ -211,6 +215,7 @@ frappe.ui.form.on("Work Order", { frappe.set_route("shop-floor"); }); } + erpnext.work_order.add_start_button(frm); if (frm.doc.status == "Completed") { if (frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture") { @@ -764,6 +769,7 @@ frappe.ui.form.on("Work Order Operation", { erpnext.work_order = { set_custom_buttons: function (frm) { var doc = frm.doc; + frm.has_start_btn = false; if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) { frm.add_custom_button( @@ -833,11 +839,6 @@ erpnext.work_order = { }, __("Create") ); - - var start_btn = frm.add_custom_button(__("Start"), function () { - erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); - }); - start_btn.addClass("btn-primary"); } else if (transfer_extra_materials && allowed_qty) { let qty = allowed_qty - @@ -951,6 +952,17 @@ erpnext.work_order = { } }, + add_start_button(frm) { + if (!frm.has_start_btn) { + return; + } + + const start_btn = frm.add_custom_button(__("Start"), () => { + erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); + }); + start_btn.addClass("btn-primary"); + }, + setup_stock_reservation(frm) { if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) { if (