diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py index e8ec823c1cf..9d02168fe1f 100644 --- a/erpnext/accounts/services/child_item_update.py +++ b/erpnext/accounts/services/child_item_update.py @@ -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( diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 084744079dc..0141d0c39e0 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -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 diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index bb2fad5cf8a..ec7d95737bb 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -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, ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 5cb07bff471..b973b0d558c 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -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() diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 8b082355035..7292f915303 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -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): diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 2cb2b4317c2..54f44ec8148 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -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