fix(manufacturing): refine corrective job cards (#58079)

This commit is contained in:
Mihir Kandoi
2026-08-12 15:43:29 +05:30
committed by GitHub
parent 68a30f1568
commit e5a0b5f61e
6 changed files with 196 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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