Merge pull request #57937 from frappe/mergify/bp/version-16-hotfix/pr-57689

feat(job_card): print quantities with their stock uom (backport #57689)
This commit is contained in:
Mihir Kandoi
2026-08-09 23:14:13 +05:30
committed by GitHub
6 changed files with 158 additions and 22 deletions

View File

@@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", {
if (remaining_qty < frm.doc.pending_qty) { if (remaining_qty < frm.doc.pending_qty) {
frm.doc.pending_qty = 0.0; frm.doc.pending_qty = 0.0;
refresh_field("pending_qty"); 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); 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")); flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty"));
dialog.set_value("completed_qty", max_completed_qty); dialog.set_value("completed_qty", max_completed_qty);
frappe.throw( 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); dialog.set_value("pending_qty", 0);
frappe.throw( frappe.throw(
__("Pending Quantity cannot be greater than {0}", [ __("Pending Quantity cannot be greater than {0}", [
flt(dialog.get_value("for_quantity")) - get_qty_with_uom(
flt(dialog.get_value("completed_qty")), 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); dialog.set_value("process_loss_qty", 0);
frappe.throw( frappe.throw(
__("Process Loss Quantity cannot be greater than {0}", [ __("Process Loss Quantity cannot be greater than {0}", [
flt(dialog.get_value("for_quantity")) - get_qty_with_uom(
flt(dialog.get_value("completed_qty")), 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) { function get_last_row(time_logs) {
return time_logs[time_logs.length - 1] || {}; return time_logs[time_logs.length - 1] || {};
} }
function get_qty_with_uom(qty, stock_uom) {
return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty);
}

View File

@@ -13,10 +13,11 @@
"work_order", "work_order",
"column_break_uqjq", "column_break_uqjq",
"production_item", "production_item",
"bom_no",
"column_break_qrpg", "column_break_qrpg",
"for_quantity", "for_quantity",
"column_break_yecz", "column_break_yecz",
"bom_no", "stock_uom",
"section_break_oisd", "section_break_oisd",
"company", "company",
"naming_series", "naming_series",
@@ -164,6 +165,13 @@
"in_preview": 1, "in_preview": 1,
"label": "Qty To Manufacture" "label": "Qty To Manufacture"
}, },
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{ {
"fieldname": "wip_warehouse", "fieldname": "wip_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
@@ -695,7 +703,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-06-19 17:39:42.293242", "modified": "2026-08-01 14:22:19.926911",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card", "name": "Job Card",

View File

@@ -130,6 +130,7 @@ class JobCard(Document):
"Cancelled", "Cancelled",
"Completed", "Completed",
] ]
stock_uom: DF.Link | None
sub_operations: DF.Table[JobCardOperation] sub_operations: DF.Table[JobCardOperation]
target_warehouse: DF.Link | None target_warehouse: DF.Link | None
time_logs: DF.Table[JobCardTimeLog] time_logs: DF.Table[JobCardTimeLog]
@@ -158,6 +159,7 @@ class JobCard(Document):
def before_validate(self): def before_validate(self):
self.set_wip_warehouse() self.set_wip_warehouse()
self.set_stock_uom()
def validate(self): def validate(self):
self.validate_time_logs() 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})." "Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})."
).format( ).format(
bold(flt(self.total_completed_qty, precision)), bold(self.get_qty_with_uom(self.total_completed_qty)),
bold(flt(self.process_loss_qty, precision)), bold(self.get_qty_with_uom(self.process_loss_qty)),
bold(flt(self.pending_qty, precision)), bold(self.get_qty_with_uom(self.pending_qty)),
bold(flt(self.for_quantity, precision)), 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}" "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
).format( ).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"), title=_("Excess Transfer"),
exc=JobCardOverTransferError, 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.""" """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) 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): def set_wip_warehouse(self):
if not self.wip_warehouse: if not self.wip_warehouse:
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_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): def validate_operation_id(self):
if ( if (
self.get("operation_id") self.get("operation_id")
@@ -1358,7 +1376,7 @@ class JobCard(Document):
previous_operations = frappe.get_all( previous_operations = frappe.get_all(
"Work Order Operation", "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)}, filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
order_by="sequence_id, idx", 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}." "The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
).format( ).format(
bold(current_operation_qty), bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation), bold(self.operation),
bold(row.completed_qty), bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
bold(row.operation), 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." "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( ).format(
bold(current_operation_qty), bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation), bold(self.operation),
bold(manufactured_qty), bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation), bold(row.operation),
), ),
OperationSequenceError, 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})." "Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
).format( ).format(
bold(flt(kwargs.qty, precision)), bold(self.get_qty_with_uom(kwargs.qty)),
bold(flt(kwargs.pending_qty, precision)), bold(self.get_qty_with_uom(kwargs.pending_qty)),
bold(flt(kwargs.process_loss_qty, precision)), bold(self.get_qty_with_uom(kwargs.process_loss_qty)),
bold(flt(kwargs.for_quantity, precision)), bold(self.get_qty_with_uom(kwargs.for_quantity)),
) )
) )

View File

@@ -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.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.work_order.work_order import WorkOrder, make_work_order
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation 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.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -901,6 +902,55 @@ class TestJobCard(ERPNextTestSuite):
)[0], )[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): 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) 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 jc.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) 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): def test_completion_qty_split_must_add_up(self):
jc = frappe.new_doc("Job Card") jc = frappe.new_doc("Job Card")
jc.for_quantity = 5 jc.for_quantity = 5

View File

@@ -497,3 +497,4 @@ erpnext.patches.v16_0.fix_subcontracting_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.rename_italy_customer_name_fields erpnext.patches.v16_0.rename_italy_customer_name_fields
erpnext.patches.v16_0.set_stock_uom_in_job_card

View File

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