From a8f189696e2a82d0ce35c01cbdbed11508df2bb5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 14:19:08 +0530 Subject: [PATCH] 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. --- .../doctype/job_card/job_card.json | 10 +++- .../doctype/job_card/job_card.py | 48 +++++++++++++------ .../page/shop_floor/shop_floor.py | 1 + erpnext/patches.txt | 1 + .../v16_0/set_stock_uom_in_job_card.py | 39 +++++++++++++++ 5 files changed, 84 insertions(+), 15 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.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 6da36cf6014..f1e3f59ee97 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -15,6 +15,7 @@ "production_item", "column_break_qrpg", "for_quantity", + "stock_uom", "column_break_yecz", "bom_no", "section_break_oisd", @@ -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:30:00.000000", "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/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..7289ec6552e --- /dev/null +++ b/erpnext/patches/v16_0/set_stock_uom_in_job_card.py @@ -0,0 +1,39 @@ +import frappe + + +def execute(): + job_cards = frappe.get_all( + "Job Card", + filters={"stock_uom": ("in", ["", None])}, + fields=["name", "finished_good", "production_item"], + ) + + if not job_cards: + return + + item_codes = {row.finished_good or row.production_item for row in job_cards} + item_codes.discard(None) + 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.auto_commit_on_many_writes = True + frappe.db.bulk_update("Job Card", updates) + frappe.db.auto_commit_on_many_writes = False