diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 3ff223ac94c..2f62c5d1304 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", { if (remaining_qty < frm.doc.pending_qty) { frm.doc.pending_qty = 0.0; refresh_field("pending_qty"); - frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty])); + frappe.throw( + __("Pending Quantity cannot be greater than {0}", [ + get_qty_with_uom(remaining_qty, frm.doc.stock_uom), + ]) + ); } const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty); @@ -274,7 +278,9 @@ frappe.ui.form.on("Job Card", { 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]) + __("Completed Quantity cannot be greater than {0}", [ + get_qty_with_uom(max_completed_qty, frm.doc.stock_uom), + ]) ); } @@ -300,8 +306,11 @@ frappe.ui.form.on("Job Card", { 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")), + get_qty_with_uom( + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + frm.doc.stock_uom + ), ]) ); } @@ -327,8 +336,11 @@ frappe.ui.form.on("Job Card", { 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")), + get_qty_with_uom( + flt(dialog.get_value("for_quantity")) - + flt(dialog.get_value("completed_qty")), + frm.doc.stock_uom + ), ]) ); } @@ -885,3 +897,7 @@ function get_last_completed_row(time_logs) { function get_last_row(time_logs) { return time_logs[time_logs.length - 1] || {}; } + +function get_qty_with_uom(qty, stock_uom) { + return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty); +} diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index c215aee42d4..ab84a259442 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -13,10 +13,11 @@ "work_order", "column_break_uqjq", "production_item", + "bom_no", "column_break_qrpg", "for_quantity", "column_break_yecz", - "bom_no", + "stock_uom", "section_break_oisd", "company", "naming_series", @@ -164,6 +165,13 @@ "in_preview": 1, "label": "Qty To Manufacture" }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, { "fieldname": "wip_warehouse", "fieldtype": "Link", @@ -695,7 +703,7 @@ "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-06-19 17:39:42.293242", + "modified": "2026-08-01 14:22:19.926911", "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 0e9d2a2419d..5c248a3e3e3 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -130,6 +130,7 @@ class JobCard(Document): "Cancelled", "Completed", ] + stock_uom: DF.Link | None sub_operations: DF.Table[JobCardOperation] target_warehouse: DF.Link | None time_logs: DF.Table[JobCardTimeLog] @@ -158,6 +159,7 @@ class JobCard(Document): def before_validate(self): self.set_wip_warehouse() + self.set_stock_uom() def validate(self): self.validate_time_logs() @@ -909,10 +911,10 @@ class JobCard(Document): _( "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)), + bold(self.get_qty_with_uom(self.total_completed_qty)), + bold(self.get_qty_with_uom(self.process_loss_qty)), + bold(self.get_qty_with_uom(self.pending_qty)), + bold(self.get_qty_with_uom(self.for_quantity)), ) ) @@ -1167,7 +1169,10 @@ class JobCard(Document): _( "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}" ).format( - row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card + row.idx, + frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)), + frappe.bold(row.item_code), + ste_doc.job_card, ), title=_("Excess Transfer"), exc=JobCardOverTransferError, @@ -1290,10 +1295,23 @@ class JobCard(Document): """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 get_qty_with_uom(self, qty, item_code=None): + """A quantity in a message reads as a count of nothing without the unit it is measured in.""" + uom = self.stock_uom + if item_code: + uom = frappe.get_cached_value("Item", item_code, "stock_uom") + + return f"{flt(qty, self.precision('total_completed_qty'))} {uom or ''}".strip() + def set_wip_warehouse(self): if not self.wip_warehouse: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") + def set_stock_uom(self): + item_code = self.finished_good or self.production_item + if item_code: + self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom") + def validate_operation_id(self): if ( self.get("operation_id") @@ -1358,7 +1376,7 @@ class JobCard(Document): previous_operations = frappe.get_all( "Work Order Operation", - fields=["name", "operation", "status", "completed_qty", "sequence_id"], + 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", ) @@ -1401,9 +1419,9 @@ class JobCard(Document): _( "The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}." ).format( - bold(current_operation_qty), + bold(self.get_qty_with_uom(current_operation_qty)), bold(self.operation), - bold(row.completed_qty), + bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)), bold(row.operation), ) ) @@ -1446,9 +1464,9 @@ class JobCard(Document): _( "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.get_qty_with_uom(current_operation_qty)), bold(self.operation), - bold(manufactured_qty), + bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), bold(row.operation), ), OperationSequenceError, @@ -1658,10 +1676,10 @@ class JobCard(Document): _( "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)), + bold(self.get_qty_with_uom(kwargs.qty)), + bold(self.get_qty_with_uom(kwargs.pending_qty)), + bold(self.get_qty_with_uom(kwargs.process_loss_qty)), + bold(self.get_qty_with_uom(kwargs.for_quantity)), ) ) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 99deafb80b0..1d58ebb5bce 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -22,6 +22,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import ( from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation +from erpnext.patches.v16_0.set_stock_uom_in_job_card import execute as set_stock_uom_in_job_card from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite @@ -901,6 +902,55 @@ class TestJobCard(ERPNextTestSuite): )[0], ) + def test_stock_uom_is_set_from_the_produced_item(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) + item_code = job_card.finished_good or job_card.production_item + + self.assertEqual(job_card.stock_uom, frappe.db.get_value("Item", item_code, "stock_uom")) + + def test_stock_uom_patch_backfills_legacy_job_cards(self): + suffix = random_string(8) + finished_good = create_item(f"Stock UOM Patch FG {suffix}", stock_uom="Kg") + production_item = create_item(f"Stock UOM Patch Product {suffix}", stock_uom="Nos") + + finished_good_job_card = self.get_first_job_card( + make_wo_order_test_record(item="_Test FG Item 2", qty=5).name + ) + production_item_job_card = self.get_first_job_card( + make_wo_order_test_record(item="_Test FG Item 2", qty=6).name + ) + + frappe.db.set_value( + "Job Card", + finished_good_job_card.name, + { + "finished_good": finished_good.name, + "production_item": production_item.name, + "stock_uom": None, + }, + update_modified=False, + ) + frappe.db.set_value( + "Job Card", + production_item_job_card.name, + {"finished_good": None, "production_item": production_item.name, "stock_uom": None}, + update_modified=False, + ) + + set_stock_uom_in_job_card() + + self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Kg") + self.assertEqual(frappe.db.get_value("Job Card", production_item_job_card.name, "stock_uom"), "Nos") + + frappe.db.set_value( + "Job Card", finished_good_job_card.name, "stock_uom", "Nos", update_modified=False + ) + set_stock_uom_in_job_card() + + self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Nos") + 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) @@ -2634,6 +2684,13 @@ class TestJobCard(ERPNextTestSuite): jc.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) + 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 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1d893d4d8ae..fc536cce751 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -497,3 +497,4 @@ erpnext.patches.v16_0.fix_subcontracting_titles erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.rename_italy_customer_name_fields +erpnext.patches.v16_0.set_stock_uom_in_job_card diff --git a/erpnext/patches/v16_0/set_stock_uom_in_job_card.py b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py new file mode 100644 index 00000000000..35abf69df05 --- /dev/null +++ b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py @@ -0,0 +1,36 @@ +import frappe + + +def execute(): + job_cards = frappe.get_all( + "Job Card", + filters={"stock_uom": ("is", "not set")}, + fields=["name", "finished_good", "production_item"], + ) + + if not job_cards: + return + + item_codes = {code for row in job_cards if (code := row.finished_good or row.production_item)} + if not item_codes: + return + + stock_uoms = dict( + frappe.get_all( + "Item", + filters={"name": ("in", list(item_codes))}, + fields=["name", "stock_uom"], + as_list=True, + ) + ) + + updates = {} + for row in job_cards: + stock_uom = stock_uoms.get(row.finished_good or row.production_item) + if stock_uom: + updates[row.name] = {"stock_uom": stock_uom} + + if not updates: + return + + frappe.db.bulk_update("Job Card", updates)