From 07ac4d83ef097c83e18b246dac5196d6c7b0656b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:31 +0530 Subject: [PATCH] feat(job_card): print quantities with their stock uom (#57689) * feat(job_card): carry the stock uom on the job card Every quantity the job card reports belongs to the item it produces, but the document had no unit of its own, so messages could only print bare numbers. Add the Stock UOM field, set from the finished good or the final product, and backfill the job cards that already exist. * fix(job_card): print quantities with their unit A bare 5 in an error says nothing about what was counted. Every message that reports a quantity now names its unit, taking it from the job card's stock uom, from the previous operation's finished good when the message compares two operations, and from the item itself for a raw material transfer. The completion dialogs read the same unit off the job card. * refactor(job_card): move the stock uom next to the qty it measures * fix(job_card): keep the stock uom backfill atomic Drop the auto commit toggle so the backfill is a single transaction with no connection flag left behind when it raises, and select the job cards to fill with an explicit unset filter instead of a value list. --- .../doctype/job_card/job_card.js | 28 ++++++++--- .../doctype/job_card/job_card.json | 12 ++++- .../doctype/job_card/job_card.py | 48 +++++++++++++------ .../doctype/job_card/test_job_card.py | 15 ++++++ .../page/shop_floor/shop_floor.py | 1 + erpnext/patches.txt | 1 + .../v16_0/set_stock_uom_in_job_card.py | 36 ++++++++++++++ erpnext/public/js/shop_floor/shop_floor.js | 14 ++++-- 8 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 erpnext/patches/v16_0/set_stock_uom_in_job_card.py diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4e5041d4c06..dedc7c20966 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); @@ -272,7 +276,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), + ]) ); } @@ -298,8 +304,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 + ), ]) ); } @@ -325,8 +334,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 + ), ]) ); } @@ -884,3 +896,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 6da36cf6014..86478b7c290 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", @@ -163,6 +164,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", @@ -689,7 +697,7 @@ "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-07-23 12:00:00.000000", + "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 3f17822ebe5..1d9544d2651 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() @@ -955,10 +957,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)), ) ) @@ -1241,7 +1243,12 @@ class JobCard(Document): frappe.throw( _( "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), + ).format( + 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, ) @@ -1316,6 +1323,14 @@ 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_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() @@ -1360,6 +1375,11 @@ class JobCard(Document): 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 set_operation_id(self): if not (self.work_order and self.operation): return @@ -1435,7 +1455,7 @@ class JobCard(Document): def get_previous_operations(self): 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", ) @@ -1494,9 +1514,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), ) ) @@ -1522,9 +1542,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, @@ -1717,10 +1737,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 8762a6a3d13..da1b40af076 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -924,6 +924,14 @@ 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_completion_qty_reduces_for_quantity_without_process_loss(self): work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5) @@ -2228,6 +2236,13 @@ class TestJobCardLogic(ERPNextTestSuite): 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 diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.py b/erpnext/manufacturing/page/shop_floor/shop_floor.py index dd4bce405c0..0ef5b99613e 100644 --- a/erpnext/manufacturing/page/shop_floor/shop_floor.py +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.py @@ -22,6 +22,7 @@ JOB_CARD_FIELDS = [ "total_completed_qty", "for_quantity", "process_loss_qty", + "stock_uom", "finished_good", "transferred_qty", "status", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 921296c3297..f2c2a447817 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -507,3 +507,4 @@ erpnext.patches.v16_0.fix_subcontracting_titles erpnext.patches.v16_0.move_warehouse_defaults_to_company erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root +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) diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js index 13b8ca657d2..a595102c004 100644 --- a/erpnext/public/js/shop_floor/shop_floor.js +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -786,6 +786,8 @@ class ShopFloor { pending = flt(jc.pending_qty); } + const qty_with_uom = (qty) => `${flt(qty)} ${jc.stock_uom || ""}`.trim(); + const fields = [ { fieldtype: "Float", @@ -819,7 +821,9 @@ class ShopFloor { 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]) + __("Completed Quantity cannot be greater than {0}", [ + qty_with_uom(max_completed_qty), + ]) ); } @@ -845,7 +849,9 @@ class ShopFloor { 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")), + qty_with_uom( + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) + ), ]) ); } @@ -872,7 +878,9 @@ class ShopFloor { 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")), + qty_with_uom( + flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")) + ), ]) ); }