mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 15:47:06 +00:00
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:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
erpnext/patches/v16_0/set_stock_uom_in_job_card.py
Normal file
36
erpnext/patches/v16_0/set_stock_uom_in_job_card.py
Normal 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)
|
||||||
Reference in New Issue
Block a user