diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 0a73efdfc4d..3dfdb9c0221 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -1510,7 +1510,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None): inverse_match = frappe.db.get_value( "UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1 ) - if inverse_match: + if inverse_match and inverse_match.value: return flt(1 / inverse_match.value, frappe.get_precision("UOM Conversion Factor", "value")) # This attempts to try and get conversion from intermediate UOM. @@ -1520,18 +1520,34 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None): # therefore kg -> mg = 1000 / 0.001 = 1,000,000 first = frappe.qb.DocType("UOM Conversion Factor").as_("first") second = frappe.qb.DocType("UOM Conversion Factor").as_("second") - intermediate_match = ( + # Conversion pairs are not unique, so document names provide stable tie-breakers. + shared_source_match = ( frappe.qb.from_(first) .join(second) .on(first.from_uom == second.from_uom) .select((first.value / second.value).as_("value")) - .where((first.to_uom == to_uom) & (second.to_uom == from_uom)) + .where((first.to_uom == to_uom) & (second.to_uom == from_uom) & (second.value != 0)) + .orderby(first.name, second.name) .limit(1) .run(as_dict=1) ) - if intermediate_match: - return flt(intermediate_match[0].value, frappe.get_precision("UOM Conversion Factor", "value")) + if shared_source_match: + return flt(shared_source_match[0].value, frappe.get_precision("UOM Conversion Factor", "value")) + + shared_target_match = ( + frappe.qb.from_(first) + .join(second) + .on(first.to_uom == second.to_uom) + .select((first.value / second.value).as_("value")) + .where((first.from_uom == from_uom) & (second.from_uom == to_uom) & (second.value != 0)) + .orderby(first.name, second.name) + .limit(1) + .run(as_dict=1) + ) + + if shared_target_match: + return flt(shared_target_match[0].value, frappe.get_precision("UOM Conversion Factor", "value")) @frappe.whitelist() diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 951d21457a5..af003bd4b37 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -71,6 +71,20 @@ def make_item(item_code=None, properties=None, uoms=None, barcode=None): return item +def make_uom_conversion_factor(from_uom, to_uom, value, category="Mass"): + for uom in (from_uom, to_uom): + if not frappe.db.exists("UOM", uom): + frappe.get_doc(doctype="UOM", uom_name=uom, category=category).insert() + + return frappe.get_doc( + doctype="UOM Conversion Factor", + category=category, + from_uom=from_uom, + to_uom=to_uom, + value=value, + ).insert() + + class TestItem(ERPNextTestSuite): def setUp(self): super().setUp() @@ -807,6 +821,32 @@ class TestItem(ERPNextTestSuite): factor = get_uom_conv_factor("Pound", "Gram") self.assertAlmostEqual(factor, 453.592, 3) + def test_uom_conv_intermediate_with_shared_target(self): + make_uom_conversion_factor("_Test 3 Kg Bag", "Kg", 3) + make_uom_conversion_factor("_Test 25 Kg Bag", "Kg", 25) + + factor = get_uom_conv_factor("_Test 3 Kg Bag", "_Test 25 Kg Bag") + + self.assertEqual(factor, 0.12) + + def test_uom_conv_intermediate_with_shared_target_is_deterministic(self): + make_uom_conversion_factor("_Test 3 Kg Bag", "Kg", 3) + make_uom_conversion_factor("_Test 25 Kg Bag", "Kg", 25) + make_uom_conversion_factor("_Test 3 Kg Bag", "Kg", 6) + make_uom_conversion_factor("_Test 25 Kg Bag", "Kg", 20) + + factor = get_uom_conv_factor("_Test 3 Kg Bag", "_Test 25 Kg Bag") + + self.assertEqual(factor, 0.12) + + def test_uom_conv_intermediate_with_shared_target_ignores_zero_divisor(self): + make_uom_conversion_factor("_Test 3 Kg Bag", "Kg", 3) + make_uom_conversion_factor("_Test 25 Kg Bag", "Kg", 0) + + factor = get_uom_conv_factor("_Test 3 Kg Bag", "_Test 25 Kg Bag") + + self.assertIsNone(factor) + def test_uom_conv_base_case(self): factor = get_uom_conv_factor("m", "m") self.assertEqual(factor, 1.0) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 1469f26ed21..f3af2f4aa4a 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -15,7 +15,7 @@ from erpnext.buying.doctype.supplier.test_supplier import create_supplier from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.stock import get_warehouse_account_map -from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.item.test_item import create_item, make_item, make_uom_conversion_factor from erpnext.stock.doctype.material_request.mapper import make_purchase_order from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -28,6 +28,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle make_serial_batch_bundle, ) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.stock.get_item_details import get_conversion_factor from erpnext.tests.utils import ERPNextTestSuite @@ -4610,6 +4611,33 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(pr.items[0].conversion_factor, 1.0) + def test_conversion_factor_via_shared_target_uom(self): + transaction_uom = "_Test 3 Kg Bag" + stock_uom = "_Test 25 Kg Bag" + make_uom_conversion_factor(transaction_uom, "Kg", 3) + make_uom_conversion_factor(stock_uom, "Kg", 25) + item = make_item("Test Item for Shared Target UOM", {"stock_uom": stock_uom}) + + conversion_factor = get_conversion_factor(item.name, transaction_uom).get("conversion_factor") + pr = make_purchase_receipt( + item_code=item.name, + qty=10, + uom=transaction_uom, + stock_uom=stock_uom, + conversion_factor=conversion_factor, + ) + + self.assertEqual(conversion_factor, 0.12) + self.assertEqual(pr.items[0].stock_qty, 1.2) + self.assertEqual( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": pr.doctype, "voucher_no": pr.name, "voucher_detail_no": pr.items[0].name}, + "actual_qty", + ), + 1.2, + ) + def test_purchase_receipt_return_valuation_without_use_serial_batch_field(self): from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return