diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9732fe9d607..408469af906 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1571,6 +1571,18 @@ class StockEntry(StockController, SubcontractingInwardController): raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no, serial_and_batch_bundle=d.serial_and_batch_bundle, + posting_datetime=get_combine_datetime(self.posting_date, self.posting_time), + creation=self.first_sle_creation, + ) + + @property + def first_sle_creation(self): + """Creation of this entry's earliest ledger entry, if it has posted any yet.""" + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}, + "creation", + order_by="creation asc", ) def has_consumption_basis(self) -> bool: diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 14d65cfb8ff..e253174c937 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -57,6 +57,19 @@ def get_sle(**args): ) +def stock_entry_row(item_code, qty, **kwargs): + stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") + return { + "item_code": item_code, + "qty": qty, + "transfer_qty": qty, + "uom": stock_uom, + "stock_uom": stock_uom, + "conversion_factor": 1, + **kwargs, + } + + class TestStockEntry(ERPNextTestSuite): def setUp(self): self.load_test_records("Stock Entry") @@ -1371,32 +1384,25 @@ class TestStockEntry(ERPNextTestSuite): make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100) - def row(item_code, qty, **kwargs): - stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") - return { - "item_code": item_code, - "qty": qty, - "transfer_qty": qty, - "uom": stock_uom, - "stock_uom": stock_uom, - "conversion_factor": 1, - **kwargs, - } - entry = frappe.new_doc("Stock Entry") entry.company = "_Test Company" entry.purpose = "Manufacture" entry.set_stock_entry_type() entry.fg_completed_qty = 1 - entry.append("items", row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) - entry.append("items", row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1)) + entry.append("items", stock_entry_row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) entry.append( "items", - row(scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap"), + stock_entry_row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1), ) entry.append( "items", - row( + stock_entry_row( + scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap" + ), + ) + entry.append( + "items", + stock_entry_row( manual_item, 1, t_warehouse="_Test Warehouse 1 - _TC", @@ -1425,6 +1431,88 @@ class TestStockEntry(ERPNextTestSuite): manual_row.valuation_type = "% of Component Cost" self.assertRaises(frappe.ValidationError, entry.save) + @ERPNextTestSuite.change_settings("Stock Reposting Settings", {"item_based_reposting": 0}) + def test_repost_values_costed_out_row_as_of_posting_date(self): + from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import repost_sl_entries + + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_method": "Moving Average"}).name + scrap_item = make_item(properties={"is_stock_item": 1}).name + + make_stock_entry( + item_code=rm_item, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=100, + posting_date=add_days(today(), -10), + ) + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=5, + basic_rate=50, + posting_date=add_days(today(), -10), + ) + + entry = frappe.new_doc("Stock Entry") + entry.company = "_Test Company" + entry.purpose = "Manufacture" + entry.set_stock_entry_type() + entry.set_posting_time = 1 + entry.posting_date = add_days(today(), -5) + entry.posting_time = "10:00:00" + entry.fg_completed_qty = 1 + entry.append("items", stock_entry_row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) + entry.append( + "items", + stock_entry_row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1), + ) + entry.append( + "items", + stock_entry_row( + scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap" + ), + ) + entry.insert() + entry.submit() + + self.assertEqual(entry.items[2].basic_rate, 50) + self.assertEqual(entry.items[1].basic_rate, 900) + + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=10, + basic_rate=5000, + posting_date=add_days(today(), -1), + ) + + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=10, + basic_rate=9000, + posting_date=add_days(today(), -5), + posting_time="10:00:00", + ) + + backdated_receipt = make_stock_entry( + item_code=rm_item, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=200, + posting_date=add_days(today(), -8), + ) + repost = frappe.db.get_value( + "Repost Item Valuation", {"voucher_no": backdated_receipt.name, "docstatus": 1}, "name" + ) + repost_sl_entries(frappe.get_doc("Repost Item Valuation", repost)) + + entry.load_from_db() + + self.assertEqual(entry.items[2].basic_rate, 50) + self.assertEqual(entry.items[1].basic_rate, 1400) + def test_valuation_rate_lookup_without_voucher_no(self): from erpnext.stock.stock_ledger import get_valuation_rate diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index f0b9e9c6d4a..ddafd88734d 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2001,6 +2001,15 @@ def get_sle_by_voucher_detail_no(voucher_detail_no): ) +def get_prior_ledger_condition(table, posting_datetime, creation): + """Restrict a ledger lookup to the entries that precede a voucher in ledger order.""" + if creation: + return (table.posting_datetime < posting_datetime) | ( + (table.posting_datetime == posting_datetime) & (table.creation < creation) + ) + return table.posting_datetime <= posting_datetime + + def get_valuation_rate( item_code, warehouse, @@ -2013,6 +2022,8 @@ def get_valuation_rate( raise_error_if_no_rate=True, batch_no=None, serial_and_batch_bundle=None, + posting_datetime=None, + creation=None, ): from erpnext.stock.serial_batch_bundle import BatchNoValuation @@ -2035,6 +2046,9 @@ def get_valuation_rate( # Comparing against a None voucher_no yields NULL, which filters out every row query = query.where((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type)) + if posting_datetime: + query = query.where(get_prior_ledger_condition(table, posting_datetime, creation)) + last_valuation_rate = query.run() if last_valuation_rate and last_valuation_rate[0][0] is not None: return flt(last_valuation_rate[0][0]) @@ -2058,25 +2072,32 @@ def get_valuation_rate( return batch_obj.get_incoming_rate() # Get valuation rate from last sle for the same item and warehouse - exclude_voucher_condition = "" - values = [item_code, warehouse] + sle_entry = frappe.qb.DocType("Stock Ledger Entry") + last_sle_query = ( + frappe.qb.from_(sle_entry) + .select(sle_entry.valuation_rate) + .where( + (sle_entry.item_code == item_code) + & (sle_entry.warehouse == warehouse) + & (sle_entry.valuation_rate >= 0) + & (sle_entry.is_cancelled == 0) + ) + .orderby(sle_entry.posting_datetime, order=frappe.qb.desc) + .orderby(sle_entry.creation, order=frappe.qb.desc) + .limit(1) + ) if voucher_no: # Comparing against a None voucher_no yields NULL, which filters out every row - exclude_voucher_condition = "AND NOT (voucher_no = %s AND voucher_type = %s)" - values.extend([voucher_no, voucher_type]) + last_sle_query = last_sle_query.where( + ~((sle_entry.voucher_no == voucher_no) & (sle_entry.voucher_type == voucher_type)) + ) - if last_valuation_rate := frappe.db.sql( # nosemgrep - f"""select valuation_rate - from `tabStock Ledger Entry` - where - item_code = %s - AND warehouse = %s - AND valuation_rate >= 0 - AND is_cancelled = 0 - {exclude_voucher_condition} - order by posting_datetime desc, creation desc limit 1""", - values, - ): + if posting_datetime: + last_sle_query = last_sle_query.where( + get_prior_ledger_condition(sle_entry, posting_datetime, creation) + ) + + if last_valuation_rate := last_sle_query.run(): return flt(last_valuation_rate[0][0]) if fallbacks: @@ -2507,13 +2528,7 @@ def get_stock_value_difference( elif voucher_no: query = query.where(table.voucher_no != voucher_no) - if creation: - query = query.where( - (table.posting_datetime < posting_datetime) - | ((table.posting_datetime == posting_datetime) & (table.creation < creation)) - ) - else: - query = query.where(table.posting_datetime <= posting_datetime) + query = query.where(get_prior_ledger_condition(table, posting_datetime, creation)) difference_amount = query.run() return flt(difference_amount[0][0]) if difference_amount else 0