mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-31 15:32:27 +00:00
fix: compare updated item quantities in stock UOM (#58603)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user