fix(stock): support shared target UOM conversions (#58305)

This commit is contained in:
Mihir Kandoi
2026-08-19 18:29:59 +05:30
committed by GitHub
parent 478a2f4f4b
commit deeb787f57
3 changed files with 90 additions and 6 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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