diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py index dfbe6d24806..1644b9a2488 100644 --- a/erpnext/stock/tests/test_utils.py +++ b/erpnext/stock/tests/test_utils.py @@ -138,3 +138,59 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin): item_scan_with_ctx = scan_barcode("w12345", ctx=ctx) self.assertEqual(item_scan_with_ctx["item_code"], item_with_warehouse.name) self.assertEqual(item_scan_with_ctx["default_warehouse"], warehouse_2.name) + + def test_get_latest_stock_qty(self): + """get_latest_stock_qty (Sum(actual_qty) over Bin; the warehouse-subtree EXISTS converted to + a qb subquery) must reflect received stock for a non-group warehouse.""" + from frappe.utils import flt + + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + from erpnext.stock.utils import get_latest_stock_qty + + warehouse = "_Test Warehouse - _TC" + item = self.make_item(properties={"is_stock_item": 1}).name + before = flt(get_latest_stock_qty(item, warehouse)) + + make_stock_entry(item_code=item, target=warehouse, qty=8, basic_rate=100) + + self.assertEqual(flt(get_latest_stock_qty(item, warehouse)), before + 8) + + def test_get_stock_value_from_bin(self): + """get_stock_value_from_bin (comma-join -> inner_join, `ifnull(disabled,0)=0` -> + `disabled==0 | isnull`) must sum the Bin stock_value for an item.""" + from frappe.utils import flt + + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + from erpnext.stock.utils import get_stock_value_from_bin + + warehouse = "_Test Warehouse - _TC" + item = self.make_item(properties={"is_stock_item": 1}).name + + make_stock_entry(item_code=item, target=warehouse, qty=5, basic_rate=50) + + # returns a single-row result set: [(stock_value,)] + self.assertEqual(flt(get_stock_value_from_bin(item_code=item)[0][0]), 5 * 50) + + def test_get_avg_purchase_rate(self): + """get_avg_purchase_rate must average Serial No purchase_rate via the dict-`AVG` get_all + field (frappe compiles `[{"AVG": "purchase_rate"}]` to `AVG(purchase_rate)` on both engines).""" + from frappe.utils import flt, random_string + + from erpnext.stock.utils import get_avg_purchase_rate + + item = self.make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name + serial_nos = [] + for rate in (10, 30): + sn = "_TAVG" + random_string(8) + frappe.get_doc( + { + "doctype": "Serial No", + "serial_no": sn, + "item_code": item, + "company": "_Test Company", + "purchase_rate": rate, + } + ).insert() + serial_nos.append(sn) + + self.assertEqual(flt(get_avg_purchase_rate("\n".join(serial_nos))), 20.0) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index d019ce2f1d8..1598860b165 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -7,7 +7,8 @@ import json import frappe from frappe import _ -from frappe.query_builder.functions import IfNull, Sum +from frappe.query_builder import Case +from frappe.query_builder.functions import Abs, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime from frappe.utils.data import DateTimeLikeObject @@ -30,32 +31,32 @@ class PendingRepostingError(frappe.ValidationError): def get_stock_value_from_bin(warehouse=None, item_code=None): - values = {} - conditions = "" - if warehouse: - conditions += """ and `tabBin`.warehouse in ( - select w2.name from `tabWarehouse` w1 - join `tabWarehouse` w2 on - w1.name = %(warehouse)s - and w2.lft between w1.lft and w1.rgt - ) """ - - values["warehouse"] = warehouse - - if item_code: - conditions += " and `tabBin`.item_code = %(item_code)s" - - values["item_code"] = item_code + bin_dt = frappe.qb.DocType("Bin") + item = frappe.qb.DocType("Item") query = ( - """select sum(stock_value) from `tabBin`, `tabItem` where 1 = 1 - and `tabItem`.name = `tabBin`.item_code and ifnull(`tabItem`.disabled, 0) = 0 %s""" - % conditions + frappe.qb.from_(bin_dt) + .inner_join(item) + .on(item.name == bin_dt.item_code) + .select(Sum(bin_dt.stock_value)) + .where((item.disabled == 0) | item.disabled.isnull()) ) - stock_value = frappe.db.sql(query, values) + if warehouse: + w1 = frappe.qb.DocType("Warehouse").as_("w1") + w2 = frappe.qb.DocType("Warehouse").as_("w2") + descendants = ( + frappe.qb.from_(w1) + .join(w2) + .on((w1.name == warehouse) & (w2.lft >= w1.lft) & (w2.lft <= w1.rgt)) + .select(w2.name) + ) + query = query.where(bin_dt.warehouse.isin(descendants)) - return stock_value + if item_code: + query = query.where(bin_dt.item_code == item_code) + + return query.run() def get_stock_value_on( @@ -177,36 +178,28 @@ def get_serial_nos_data(serial_nos): @frappe.whitelist() def get_latest_stock_qty(item_code: str, warehouse: str | None = None): - values, condition = [item_code], "" + bin_dt = frappe.qb.DocType("Bin") + query = frappe.qb.from_(bin_dt).select(Sum(bin_dt.actual_qty)).where(bin_dt.item_code == item_code) + if warehouse: lft, rgt, is_group = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt", "is_group"]) if is_group: - values.extend([lft, rgt]) - condition += "and exists (\ - select name from `tabWarehouse` wh where wh.name = tabBin.warehouse\ - and wh.lft >= %s and wh.rgt <= %s)" - + wh = frappe.qb.DocType("Warehouse") + query = query.where( + bin_dt.warehouse.isin( + frappe.qb.from_(wh).select(wh.name).where((wh.lft >= lft) & (wh.rgt <= rgt)) + ) + ) else: - values.append(warehouse) - condition += " AND warehouse = %s" + query = query.where(bin_dt.warehouse == warehouse) - actual_qty = frappe.db.sql( - f"""select sum(actual_qty) from tabBin - where item_code=%s {condition}""", - values, - )[0][0] - - return actual_qty + return query.run()[0][0] def get_latest_stock_balance(): bin_map = {} - for d in frappe.db.sql( - """SELECT item_code, warehouse, stock_value as stock_value - FROM tabBin""", - as_dict=1, - ): + for d in frappe.get_all("Bin", fields=["item_code", "warehouse", "stock_value"]): bin_map.setdefault(d.warehouse, {}).setdefault(d.item_code, flt(d.stock_value)) return bin_map @@ -348,12 +341,9 @@ def get_avg_purchase_rate(serial_nos): serial_nos = get_valid_serial_nos(serial_nos) return flt( - frappe.db.sql( - """select avg(purchase_rate) from `tabSerial No` - where name in (%s)""" - % ", ".join(["%s"] * len(serial_nos)), - tuple(serial_nos), - )[0][0] + frappe.get_all( + "Serial No", filters={"name": ["in", serial_nos]}, fields=[{"AVG": "purchase_rate", "as": "rate"}] + )[0].rate ) @@ -520,13 +510,19 @@ def add_additional_uom_columns(columns, result, include_uom, conversion_factors) def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, voucher_detail_no): - outgoing_rate = frappe.db.sql( - """SELECT CASE WHEN actual_qty = 0 THEN 0 ELSE abs(stock_value_difference / actual_qty) END - FROM `tabStock Ledger Entry` - WHERE voucher_type = %s and voucher_no = %s - and item_code = %s and voucher_detail_no = %s - ORDER BY CREATION DESC limit 1""", - (voucher_type, voucher_no, item_code, voucher_detail_no), + sle = frappe.qb.DocType("Stock Ledger Entry") + outgoing_rate = ( + frappe.qb.from_(sle) + .select(Case().when(sle.actual_qty == 0, 0).else_(Abs(sle.stock_value_difference / sle.actual_qty))) + .where( + (sle.voucher_type == voucher_type) + & (sle.voucher_no == voucher_no) + & (sle.item_code == item_code) + & (sle.voucher_detail_no == voucher_detail_no) + ) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + .run() ) outgoing_rate = outgoing_rate[0][0] if outgoing_rate else 0.0