fix: compare updated item quantities in stock UOM (#58603)

This commit is contained in:
Mihir Kandoi
2026-08-31 17:05:30 +05:30
committed by GitHub
parent d5789c2e8b
commit c755e24731
6 changed files with 196 additions and 22 deletions

View File

@@ -32,8 +32,7 @@ class ChildItemUpdater:
self.child_docname = child_docname
self.parent = frappe.get_doc(parent_doctype, parent_doctype_name)
self.allow_zero_qty = get_allow_zero_qty(parent_doctype)
self._ordered_items: dict | None = None
self._purchased_items: dict | None = None
self._transacted_stock_qty: dict | None = None
def update(self, trans_items: str | list) -> None:
"""Process item additions, edits, and deletions from trans_items JSON."""
@@ -48,11 +47,15 @@ class ChildItemUpdater:
self._check_permissions("write")
if self.parent_doctype == "Quotation":
self._ordered_items = get_ordered_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
self._transacted_stock_qty = get_ordered_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
elif self.parent_doctype == "Supplier Quotation":
self._purchased_items = get_purchased_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
self._transacted_stock_qty = get_purchased_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
else:
items_added_or_removed |= validate_and_delete_children(self.parent, data)
@@ -71,6 +74,7 @@ class ChildItemUpdater:
else:
self._check_permissions("write")
child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname"))
d["conversion_factor"] = self._get_new_conversion_factor(child_item, d)
change_state = get_child_item_change_state(self.parent_doctype, child_item, d)
rate_unchanged = change_state.rate_unchanged
@@ -251,6 +255,22 @@ class ChildItemUpdater:
item_row,
)
def _get_new_conversion_factor(self, child_item, new_data: dict) -> float:
current_factor = flt(child_item.get("conversion_factor")) or 1
uom = new_data.get("uom") or child_item.get("uom")
if uom == child_item.get("stock_uom"):
return 1
requested_factor = flt(new_data.get("conversion_factor"))
if requested_factor:
return requested_factor
if uom == child_item.get("uom"):
return current_factor
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
frappe.throw(
@@ -264,24 +284,24 @@ class ChildItemUpdater:
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
}
old_conversion_factor = flt(child_item.get("conversion_factor")) or 1
new_conversion_factor = flt(new_data.get("conversion_factor")) or old_conversion_factor
new_stock_qty = flt(new_data.get("qty")) * new_conversion_factor
if self.parent_doctype in qty_limits:
qty_field, error_message = qty_limits[self.parent_doctype]
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
if new_stock_qty < old_stock_qty:
frappe.throw(
_("Row #{0}:").format(new_data.get("idx")) + error_message,
title=_("Invalid Qty"),
)
if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
if not self._transacted_stock_qty:
return
items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items
if not items_map:
return
qty_to_check = items_map.get(child_item.name)
if not qty_to_check:
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
if not old_stock_qty:
return
if not rate_unchanged:
@@ -291,7 +311,7 @@ class ChildItemUpdater:
).format(frappe.bold(new_data.get("item_code")))
)
if flt(new_data.get("qty")) < qty_to_check:
if new_stock_qty < old_stock_qty:
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
@@ -581,22 +601,18 @@ def update_child_item_rate_and_discount(
def update_child_item_uom_and_weight(child_item, new_data) -> None:
conv_fac_precision = child_item.precision("conversion_factor") or 2
if new_data.get("conversion_factor"):
if child_item.stock_uom == child_item.uom:
child_item.conversion_factor = 1
else:
child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
child_item.conversion_factor = flt(new_data.get("conversion_factor"))
if new_data.get("uom"):
child_item.uom = new_data.get("uom")
conversion_factor = flt(
get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
)
child_item.conversion_factor = (
flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
)
child_item.conversion_factor = flt(new_data.get("conversion_factor")) or conversion_factor
if child_item.get("weight_per_unit"):
child_item.total_weight = flt(

View File

@@ -333,6 +333,38 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.get("items")[0].amount, 1400)
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 3)
def test_update_child_qty_with_conversion_factor_after_receipt(self):
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
purchase_order = create_purchase_order(item_code=item.item_code, qty=6, do_not_save=True)
purchase_order.items[0].uom = "Box"
purchase_order.items[0].conversion_factor = 5
purchase_order.save()
purchase_order.submit()
create_pr_against_po(purchase_order.name, 2)
row = purchase_order.items[0]
trans_items = json.dumps(
[
{
"item_code": row.item_code,
"rate": row.rate,
"qty": 4,
"uom": row.uom,
"conversion_factor": 2,
"docname": row.name,
}
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot set quantity less than received quantity",
update_child_qty_rate,
"Purchase Order",
trans_items,
purchase_order.name,
)
def test_update_child_adding_new_item(self):
po = create_purchase_order(do_not_save=1)
po.items[0].qty = 4

View File

@@ -262,7 +262,7 @@ def get_purchased_items(supplier_quotation: str):
frappe.get_all(
"Purchase Order Item",
filters={"supplier_quotation": supplier_quotation, "docstatus": 1},
fields=["supplier_quotation_item", {"SUM": "qty"}],
fields=["supplier_quotation_item", {"SUM": "stock_qty"}],
group_by="supplier_quotation_item",
as_list=1,
)

View File

@@ -127,6 +127,52 @@ class TestPurchaseOrder(ERPNextTestSuite):
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
)
def test_update_child_qty_with_conversion_factor_after_purchase(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.items[0].item_code = item.item_code
supplier_quotation.items[0].qty = 6
supplier_quotation.items[0].uom = "Box"
supplier_quotation.items[0].conversion_factor = 5
supplier_quotation.insert()
supplier_quotation.submit()
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.schedule_date = add_days(today(), 1)
purchase_order.items[0].qty = 2
purchase_order.save()
purchase_order.submit()
def update_qty(qty):
row = supplier_quotation.items[0]
trans_items = json.dumps(
[
{
"item_code": row.item_code,
"rate": row.rate,
"qty": qty,
"uom": row.uom,
"conversion_factor": 2,
"docname": row.name,
}
]
)
update_child_qty_rate("Supplier Quotation", trans_items, supplier_quotation.name)
update_qty(5)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.items[0].conversion_factor, 2)
self.assertEqual(supplier_quotation.items[0].stock_qty, 10)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot reduce quantity than ordered or purchased quantity",
update_qty,
4,
)
def test_update_supplier_quotation_child_remove_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()

View File

@@ -156,6 +156,46 @@ class TestQuotation(ERPNextTestSuite):
qo.reload()
self.assertEqual(len(qo.get("items")), 1)
def test_update_child_qty_with_uom_conversion_factor(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
quotation = make_quotation(item_code=item.item_code, qty=6, uom="Box", do_not_submit=1)
quotation.submit()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
sales_order.items[0].qty = 2
sales_order.save()
sales_order.submit()
quotation.reload()
self.assertEqual(quotation.items[0].ordered_qty, 10)
def update_qty(qty, conversion_factor=None):
item = quotation.items[0]
trans_items = json.dumps(
[
{
"item_code": item.item_code,
"description": item.description,
"rate": item.rate,
"qty": qty,
"uom": item.uom,
"conversion_factor": conversion_factor or item.conversion_factor,
"docname": item.name,
}
]
)
update_child_qty_rate("Quotation", trans_items, quotation.name)
update_qty(5, conversion_factor=2)
quotation.reload()
self.assertEqual(quotation.items[0].conversion_factor, 2)
self.assertEqual(quotation.items[0].stock_qty, 10)
self.assertRaises(frappe.ValidationError, update_qty, 4)
def test_quotation_qty(self):
qo = make_quotation(qty=0, do_not_save=True)
with self.assertRaises(InvalidQtyError):

View File

@@ -978,6 +978,46 @@ class TestSalesOrder(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name)
def test_update_child_qty_with_conversion_factor_after_delivery(self):
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
sales_order = make_sales_order(item_code=item.item_code, qty=6, uom="Box")
create_dn_against_so(sales_order.name, 2)
row = sales_order.items[0]
trans_items = json.dumps(
[
{
"item_code": row.item_code,
"rate": row.rate,
"qty": 4,
"uom": row.uom,
"conversion_factor": 2,
"docname": row.name,
}
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot set quantity less than delivered quantity",
update_child_qty_rate,
"Sales Order",
trans_items,
sales_order.name,
)
def test_update_child_preserves_conversion_factor_precision(self):
from erpnext.accounts.services.child_item_update import update_child_item_uom_and_weight
item = make_item(properties={"stock_uom": "Kg"}, uoms=[{"uom": "Box", "conversion_factor": 2}])
sales_order = make_sales_order(item_code=item.item_code, qty=6, uom="Box")
conversion_factor = 1.123456789123
row = sales_order.items[0]
update_child_item_uom_and_weight(row, {"conversion_factor": conversion_factor})
self.assertEqual(row.conversion_factor, conversion_factor)
def test_update_child_with_precision(self):
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.model.meta import get_field_precision