From 004818e0ac481061517eea7c6971b5f4d6474a48 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 24 May 2026 12:27:48 +0530 Subject: [PATCH] fix: consider batchwise valuation in stock ageing report (#54919) --- .../stock/report/stock_ageing/stock_ageing.py | 428 +++++++++++--- .../report/stock_ageing/test_stock_ageing.py | 522 ++++++++++++++++++ erpnext/tests/utils.py | 3 + 3 files changed, 866 insertions(+), 87 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index ba735d82b03..77b8056f663 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -7,7 +7,7 @@ from operator import itemgetter import frappe from frappe import _ -from frappe.query_builder.functions import Count +from frappe.query_builder.functions import Abs, Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -31,7 +31,7 @@ def execute(filters: Filters = None) -> tuple: def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[dict]: "Returns ordered, formatted data with ranges." - _func = itemgetter(1) + _func = itemgetter(-2) data = [] precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) @@ -48,15 +48,14 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li if not fifo_queue: continue + if details.has_batch_no: + fifo_queue = [slot[2:] if len(slot) == 5 else slot for slot in fifo_queue] + average_age = get_average_age(fifo_queue, to_date) earliest_age = date_diff(to_date, fifo_queue[0][1]) latest_age = date_diff(to_date, fifo_queue[-1][1]) range_values = get_range_age(filters, fifo_queue, to_date, item_dict) - check_and_replace_valuations_if_moving_average( - range_values, details.valuation_method, details.valuation_rate, filters.get("company") - ) - row = [details.name, details.item_name, details.description, details.item_group, details.brand] if filters.get("show_warehouse_wise_stock"): @@ -78,17 +77,6 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li return data -def check_and_replace_valuations_if_moving_average( - range_values, item_valuation_method, valuation_rate, company -): - if item_valuation_method == "Moving Average" or ( - not item_valuation_method - and frappe.get_cached_value("Company", company, "valuation_method") == "Moving Average" - ): - for i in range(0, len(range_values), 2): - range_values[i + 1] = range_values[i] * valuation_rate - - def get_average_age(fifo_queue: list, to_date: str) -> float: batch_age = age_qty = total_qty = 0.0 for batch in fifo_queue: @@ -239,7 +227,9 @@ class FIFOSlots: def __init__(self, filters: dict | None = None, sle: list | None = None): self.item_details = {} self.transferred_item_details = {} - self.serial_no_batch_purchase_details = {} + self.serial_no_details = {} + self.batch_no_details = {} + self.batchwise_valuation_by_batch = {} self.filters = filters self.sle = sle @@ -258,8 +248,10 @@ class FIFOSlots: stock_ledger_entries = self.sle bundle_wise_serial_nos = frappe._dict({}) + bundle_wise_batch_nos = frappe._dict({}) if stock_ledger_entries is None: bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos() + bundle_wise_batch_nos = self.__get_bundle_wise_batch_nos() # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() @@ -291,17 +283,40 @@ class FIFOSlots: d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] - if d.serial_and_batch_bundle and d.has_serial_no: - if bundle_wise_serial_nos: - serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] - else: - serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] + batch_nos = ( + [ + [ + d.batch_no.upper(), + self.__get_batchwise_valuation(d.batch_no), + abs(d.actual_qty), + abs(d.stock_value_difference), + ] + ] + if d.batch_no + else [] + ) + if d.serial_and_batch_bundle: + if d.has_serial_no: + if bundle_wise_serial_nos: + serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] + else: + serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] + elif d.has_batch_no: + if bundle_wise_batch_nos: + batch_nos = bundle_wise_batch_nos.get(d.serial_and_batch_bundle) or [] + else: + batch_nos = ( + self.__get_bundle_wise_batch_nos(d.serial_and_batch_bundle).get( + d.serial_and_batch_bundle + ) + or [] + ) serial_nos = self.uppercase_serial_nos(serial_nos) if d.actual_qty > 0: - self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos) + self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos) + self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) self.__update_balances(d, key) @@ -326,6 +341,14 @@ class FIFOSlots: "Convert serial nos to uppercase for uniformity." return [sn.upper() for sn in serial_nos] + def __get_batchwise_valuation(self, batch_no: str): + if batch_no not in self.batchwise_valuation_by_batch: + self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value( + "Batch", batch_no, "use_batchwise_valuation" + ) + + return self.batchwise_valuation_by_batch[batch_no] + def __init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -338,102 +361,286 @@ class FIFOSlots: return key, fifo_queue, transferred_item_key - def __compute_incoming_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def __compute_incoming_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on inward stock." + def set_fifo_queue_for_serial_items(): + valuation = row.stock_value_difference / row.actual_qty + for serial_no in serial_nos: + if self.serial_no_details.get(serial_no): + fifo_queue.append([serial_no, self.serial_no_details.get(serial_no), valuation]) + else: + self.serial_no_details.setdefault(serial_no, row.posting_date) + fifo_queue.append([serial_no, row.posting_date, valuation]) + + def set_fifo_queue_for_batch_items(): + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + qty, stock_value_difference = neutralize_negative_batch_stock( + batch_no, use_batchwise_valuation, qty, stock_value_difference + ) + + if not qty: + continue + + if self.batch_no_details.get(batch_no): + fifo_queue.append( + [ + batch_no, + use_batchwise_valuation, + qty, + self.batch_no_details.get(batch_no), + stock_value_difference, + ] + ) + else: + self.batch_no_details.setdefault(batch_no, row.posting_date) + fifo_queue.append( + [batch_no, use_batchwise_valuation, qty, row.posting_date, stock_value_difference] + ) + + def neutralize_negative_batch_stock(batch_no, use_batchwise_valuation, qty, stock_value_difference): + qty = flt(qty) + stock_value_difference = flt(stock_value_difference) + + if not qty: + return qty, stock_value_difference + + for slot in list(fifo_queue): + if ( + len(slot) != 5 + or slot[0] != batch_no + or slot[1] != use_batchwise_valuation + or flt(slot[2]) >= 0 + ): + continue + + qty_to_adjust = min(qty, abs(flt(slot[2]))) + value_to_adjust = ( + stock_value_difference + if qty_to_adjust == qty + else flt(stock_value_difference * (qty_to_adjust / qty)) + ) + + slot[2] = flt(slot[2]) + qty_to_adjust + slot[3] = row.posting_date + slot[4] = flt(slot[4]) + value_to_adjust + + qty = flt(qty - qty_to_adjust) + stock_value_difference = flt(stock_value_difference - value_to_adjust) + + if not flt(slot[2]) and not flt(slot[4]): + fifo_queue.remove(slot) + + if not qty: + break + + return qty, stock_value_difference + transfer_data = self.transferred_item_details.get(transfer_key) if transfer_data: # inward/outward from same voucher, item & warehouse # eg: Repack with same item, Stock reco for batch item # consume transfer data and add stock to fifo queue - self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) + self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row, batch_nos) else: - if not serial_nos and not row.get("has_serial_no"): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock - fifo_queue[0][0] += flt(row.actual_qty) - fifo_queue[0][1] = row.posting_date - fifo_queue[0][2] += flt(row.stock_value_difference) - else: - fifo_queue.append( - [flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)] - ) - return + if serial_nos and row.get("has_serial_no"): + set_fifo_queue_for_serial_items() + elif batch_nos and row.get("has_batch_no"): + set_fifo_queue_for_batch_items() + elif fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock + fifo_queue[0][0] += flt(row.actual_qty) + fifo_queue[0][1] = row.posting_date + fifo_queue[0][2] += flt(row.stock_value_difference) + else: + fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) - valuation = row.stock_value_difference / row.actual_qty - for serial_no in serial_nos: - if self.serial_no_batch_purchase_details.get(serial_no): - fifo_queue.append( - [serial_no, self.serial_no_batch_purchase_details.get(serial_no), valuation] - ) - else: - self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date) - fifo_queue.append([serial_no, row.posting_date, valuation]) - - def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def __compute_outgoing_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on outward stock." if serial_nos: fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos] - return + elif batch_nos: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + items_to_remove = [] - qty_to_pop = abs(row.actual_qty) - stock_value = abs(row.stock_value_difference) + for slot in fifo_queue: + slot_batch_no, slot_use_batchwise_valuation, slot_qty, _, slot_stock_value = slot - while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] - if 0 < flt(slot[0]) <= qty_to_pop: - # qty to pop >= slot qty - # if +ve and not enough or exactly same balance in current slot, consume whole slot - qty_to_pop -= flt(slot[0]) - stock_value -= flt(slot[2]) - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) - elif not fifo_queue: - # negative stock, no balance but qty yet to consume - fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) - self.transferred_item_details[transfer_key].append( - [qty_to_pop, row.posting_date, stock_value] - ) - qty_to_pop = 0 - stock_value = 0 - else: - # qty to pop < slot qty, ample balance - # consume actual_qty from first slot - slot[0] = flt(slot[0]) - qty_to_pop - slot[2] = flt(slot[2]) - stock_value - self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) - qty_to_pop = 0 - stock_value = 0 + if flt(slot_qty) <= 0: + continue - def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict): + # Batchwise valuation: consume only from same batch + if use_batchwise_valuation: + if slot_batch_no != batch_no: + continue + # Non-batchwise valuation: consume from any non-batchwise batch + else: + if slot_use_batchwise_valuation: + continue + + if flt(slot_qty) <= qty: + qty -= flt(slot_qty) + stock_value_difference -= flt(slot_stock_value) + self.transferred_item_details[transfer_key].append( + [flt(slot_qty), slot[3], flt(slot_stock_value)] + ) + items_to_remove.append(slot) + else: + slot[2] = flt(slot_qty) - qty + # preserve ledger valuation (moving average / SLE value), not slot proportional value + slot[4] = flt(slot_stock_value) - stock_value_difference + self.transferred_item_details[transfer_key].append( + [qty, slot[3], stock_value_difference] + ) + qty = 0 + stock_value_difference = 0 + break + + for item in items_to_remove: + fifo_queue.remove(item) + + if qty: + fifo_queue.append( + [ + batch_no, + use_batchwise_valuation, + -(qty), + row.posting_date, + -(stock_value_difference), + ] + ) + self.transferred_item_details[transfer_key].append( + [qty, row.posting_date, stock_value_difference] + ) + else: + qty_to_pop = abs(row.actual_qty) + stock_value = abs(row.stock_value_difference) + + while qty_to_pop: + slot = fifo_queue[0] if fifo_queue else [0, None, 0] + if 0 < flt(slot[0]) <= qty_to_pop: + # qty to pop >= slot qty + # if +ve and not enough or exactly same balance in current slot, consume whole slot + qty_to_pop -= flt(slot[0]) + stock_value -= flt(slot[2]) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + elif not fifo_queue: + # negative stock, no balance but qty yet to consume + fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) + self.transferred_item_details[transfer_key].append( + [qty_to_pop, row.posting_date, stock_value] + ) + qty_to_pop = 0 + stock_value = 0 + else: + # qty to pop < slot qty, ample balance + # consume actual_qty from first slot + slot[0] = flt(slot[0]) - qty_to_pop + slot[2] = flt(slot[2]) - stock_value + self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) + qty_to_pop = 0 + stock_value = 0 + + def __adjust_incoming_transfer_qty( + self, transfer_data: dict, fifo_queue: list, row: dict, batch_nos: list | None = None + ): "Add previously removed stock back to FIFO Queue." transfer_qty_to_pop = flt(row.actual_qty) stock_value = flt(row.stock_value_difference) + batch_nos = [list(batch_no) for batch_no in batch_nos or []] + + def is_batch_slot(slot): + return len(slot) == 5 + + def get_incoming_slots(qty, posting_date, value): + if not batch_nos: + return [[qty, posting_date, value]] + + incoming_slots = [] + remaining_qty = flt(qty) + remaining_value = flt(value) + + while remaining_qty and batch_nos: + batch_no, use_batchwise_valuation, batch_qty, _ = batch_nos[0] + batch_qty = flt(batch_qty) + slot_qty = min(batch_qty, remaining_qty) + slot_value = ( + remaining_value + if slot_qty == remaining_qty + else flt(remaining_value * (slot_qty / remaining_qty)) + ) + + incoming_slots.append([batch_no, use_batchwise_valuation, slot_qty, posting_date, slot_value]) + + batch_nos[0][2] = flt(batch_qty - slot_qty) + if not batch_nos[0][2]: + batch_nos.pop(0) + + remaining_qty = flt(remaining_qty - slot_qty) + remaining_value = flt(remaining_value - slot_value) + + if remaining_qty: + incoming_slots.append([remaining_qty, posting_date, remaining_value]) + + return incoming_slots def add_to_fifo_queue(slot): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock + matching_negative_batch_slot = next( + ( + existing_slot + for existing_slot in fifo_queue + if is_batch_slot(existing_slot) + and is_batch_slot(slot) + and flt(existing_slot[2]) <= 0 + and existing_slot[0] == slot[0] + and existing_slot[1] == slot[1] + ), + None, + ) + + if ( + fifo_queue + and not is_batch_slot(fifo_queue[0]) + and not is_batch_slot(slot) + and flt(fifo_queue[0][0]) <= 0 + ): fifo_queue[0][0] += flt(slot[0]) fifo_queue[0][1] = slot[1] fifo_queue[0][2] += flt(slot[2]) + elif matching_negative_batch_slot: + matching_negative_batch_slot[2] += flt(slot[2]) + matching_negative_batch_slot[3] = slot[3] + matching_negative_batch_slot[4] += flt(slot[4]) else: fifo_queue.append(slot) while transfer_qty_to_pop: - if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: + if transfer_data and 0 < flt(transfer_data[0][0]) <= transfer_qty_to_pop: # bucket qty is not enough, consume whole - transfer_qty_to_pop -= transfer_data[0][0] - stock_value -= transfer_data[0][2] - add_to_fifo_queue(transfer_data.pop(0)) + transfer_qty = flt(transfer_data[0][0]) + transfer_date = transfer_data[0][1] + transfer_value = flt(transfer_data[0][2]) + transfer_qty_to_pop -= transfer_qty + stock_value -= transfer_value + for slot in get_incoming_slots(transfer_qty, transfer_date, transfer_value): + add_to_fifo_queue(slot) + transfer_data.pop(0) elif not transfer_data: # transfer bucket is empty, extra incoming qty - add_to_fifo_queue([transfer_qty_to_pop, row.posting_date, stock_value]) + for slot in get_incoming_slots(transfer_qty_to_pop, row.posting_date, stock_value): + add_to_fifo_queue(slot) transfer_qty_to_pop = 0 stock_value = 0 else: # ample bucket qty to consume transfer_data[0][0] -= transfer_qty_to_pop transfer_data[0][2] -= stock_value - add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1], stock_value]) + for slot in get_incoming_slots(transfer_qty_to_pop, transfer_data[0][1], stock_value): + add_to_fifo_queue(slot) transfer_qty_to_pop = 0 stock_value = 0 @@ -445,6 +652,7 @@ class FIFOSlots: self.item_details[key]["total_qty"] += row.actual_qty self.item_details[key]["has_serial_no"] = row.has_serial_no + self.item_details[key]["has_batch_no"] = row.has_batch_no self.item_details[key]["details"].valuation_rate = row.valuation_rate def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict: @@ -468,6 +676,7 @@ class FIFOSlots: item_row["qty_after_transaction"] += flt(row["qty_after_transaction"]) item_row["total_qty"] += flt(row["total_qty"]) item_row["has_serial_no"] = row["has_serial_no"] + item_row["has_batch_no"] = row["has_batch_no"] return item_aggregated_data @@ -486,8 +695,8 @@ class FIFOSlots: item.brand, item.description, item.stock_uom, + item.has_batch_no, item.has_serial_no, - item.valuation_method, sle.actual_qty, sle.stock_value_difference, sle.valuation_rate, @@ -556,6 +765,51 @@ class FIFOSlots: return bundle_wise_serial_nos + def __get_bundle_wise_batch_nos(self, sabb_name=None) -> dict: + bundle = frappe.qb.DocType("Serial and Batch Bundle") + entry = frappe.qb.DocType("Serial and Batch Entry") + batch = frappe.qb.DocType("Batch") + + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + query = ( + frappe.qb.from_(bundle) + .join(entry) + .on(bundle.name == entry.parent) + .join(batch) + .on(entry.batch_no == batch.name) + .select( + bundle.name, + entry.batch_no, + batch.use_batchwise_valuation, + Abs(entry.qty).as_("qty"), + Abs(entry.stock_value_difference).as_("stock_value_difference"), + ) + .where( + (bundle.docstatus == 1) + & (entry.batch_no.isnotnull()) + & (bundle.company == self.filters.get("company")) + & (bundle.posting_datetime <= to_date) + ) + ) + + for field in ["item_code"]: + if self.filters.get(field): + query = query.where(bundle[field] == self.filters.get(field)) + + if self.filters.get("warehouse"): + query = self.__get_warehouse_conditions(bundle, query) + + if sabb_name: + query = query.where(bundle.name == sabb_name) + + bundle_wise_batch_nos = frappe._dict({}) + for bundle_name, batch_no, use_batchwise_valuation, qty, stock_value_difference in query.run(): + bundle_wise_batch_nos.setdefault(bundle_name, []).append( + [batch_no.upper(), use_batchwise_valuation, qty, stock_value_difference] + ) + + return bundle_wise_batch_nos + def __get_item_query(self) -> str: item_table = frappe.qb.DocType("Item") @@ -567,7 +821,7 @@ class FIFOSlots: "brand", "item_group", "has_serial_no", - "valuation_method", + "has_batch_no", ) if self.filters.get("item_code"): diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 4e2e5ca9d88..dee556e1e88 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -868,6 +868,528 @@ class TestStockAgeing(ERPNextTestSuite): range_valuations = range_values[1::2] self.assertEqual(range_valuations, [15, 7.5, 20, 5]) + def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self): + item_details = { + "Batch Mixed Item": { + "details": frappe._dict( + name="Batch Mixed Item", + item_name="Batch Mixed Item", + description="Batch Mixed Item", + item_group=None, + brand=None, + has_batch_no=True, + stock_uom="Nos", + ), + "fifo_queue": [ + ["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0], + [3.0, "2021-12-02", 30.0], + ], + "has_serial_no": False, + "total_qty": 8.0, + } + } + + report_data = format_report_data(self.filters, item_details, self.filters["to_date"]) + + self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + def test_batchwise_valuation(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Valuation", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id, use_batchwise_valuation): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation) + + batchwise_above_90 = "SA-BATCHWISE-ABOVE-90" + non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90" + batchwise_61_90 = "SA-BATCHWISE-61-90" + non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90" + batchwise_31_60 = "SA-BATCHWISE-31-60" + non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60" + batchwise_0_30 = "SA-BATCHWISE-0-30" + non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30" + + for batch_id, use_batchwise_valuation in { + batchwise_above_90: 1, + non_batchwise_above_90: 0, + batchwise_61_90: 1, + non_batchwise_61_90: 0, + batchwise_31_60: 1, + non_batchwise_31_60: 0, + batchwise_0_30: 1, + non_batchwise_0_30: 0, + }.items(): + make_batch(batch_id, use_batchwise_valuation) + + qty_after_transaction = 0 + + def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference): + nonlocal qty_after_transaction + + qty_after_transaction += actual_qty + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after_transaction, + stock_value_difference=stock_value_difference, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + + sle = [ + make_sle("2021-08-01", "001", batchwise_above_90, 50, 500), + make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600), + make_sle("2021-08-20", "003", batchwise_above_90, -10, -100), + make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150), + make_sle("2021-09-20", "005", batchwise_61_90, 40, 400), + make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500), + make_sle("2021-09-30", "007", batchwise_61_90, -5, -50), + make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200), + make_sle("2021-10-20", "009", batchwise_31_60, 30, 300), + make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400), + make_sle("2021-10-30", "011", batchwise_31_60, -8, -80), + make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250), + make_sle("2021-11-20", "013", batchwise_0_30, 20, 200), + make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300), + make_sle("2021-11-30", "015", batchwise_0_30, -6, -60), + make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 221.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [batchwise_above_90, 1, 40.0, "2021-08-01", 400.0], + [batchwise_61_90, 1, 35.0, "2021-09-20", 350.0], + [non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0], + [batchwise_31_60, 1, 22.0, "2021-10-20", 220.0], + [non_batchwise_31_60, 0, 40, "2021-10-25", 400], + [batchwise_0_30, 1, 14.0, "2021-11-20", 140.0], + [non_batchwise_0_30, 0, 30, "2021-11-25", 300], + ], + ) + + report_data = format_report_data(self.filters, slots, self.filters["to_date"]) + range_values = report_data[0][7:15] + self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0]) + + def test_batchwise_valuation_same_voucher_transfer(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Transfer", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1) + + source_batch = "SA-BATCHWISE-TRANSFER-SOURCE" + target_batch = "SA-BATCHWISE-TRANSFER-TARGET" + make_batch(source_batch) + make_batch(target_batch) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=20, + qty_after_transaction=20, + stock_value_difference=200, + warehouse="WH 1", + posting_date="2021-09-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-15, + qty_after_transaction=5, + stock_value_difference=-150, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=10, + qty_after_transaction=15, + stock_value_difference=100, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=target_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["total_qty"], 15.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [source_batch, 1, 5.0, "2021-09-01", 50.0], + [target_batch, 1, 10.0, "2021-09-01", 100.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[5.0, "2021-09-01", 50.0]], + ) + + def test_batchwise_valuation_negative_stock_same_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Stock", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-STOCK" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]] + ) + + sle.append( + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ) + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_neutralizes_non_head_negative_batch(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Non Head", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER" + negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD" + for batch_no in [buffer_batch, negative_batch]: + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=5, + qty_after_transaction=5, + stock_value_difference=50, + warehouse="WH 1", + posting_date="2021-11-30", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=buffer_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-5, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=1, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual( + item_result["fifo_queue"], + [ + [buffer_batch, 1, 5, "2021-11-30", 50], + [negative_batch, 1, -4.0, "2021-12-01", -40.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_negative_stock_later_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Later Voucher", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-11-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-11-10", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], -4.0) + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]]) + + def test_batchwise_valuation_stock_reconciliation_with_bundle(self): + from frappe.utils import add_days, getdate, nowdate + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + suffix = frappe.generate_hash(length=8).upper() + item_code = make_item( + f"Test Stock Ageing Batch Reco {suffix}", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": f"SA-RECO-{suffix}-.###", + "valuation_method": "FIFO", + }, + ).name + warehouse = "_Test Warehouse - _TC" + base_date = nowdate() + + opening_reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=12, + rate=10, + posting_date=add_days(base_date, -2), + posting_time="10:00:00", + ) + batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=10, + batch_no=batch_no, + posting_date=add_days(base_date, -1), + posting_time="10:00:00", + ) + + filters = frappe._dict( + company="_Test Company", + to_date=base_date, + ranges=["30", "60", "90"], + item_code=item_code, + ) + slots = FIFOSlots(filters).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 5.0) + self.assertEqual( + item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] + ) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index 70f12142fac..99c55b6d7ba 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -3003,6 +3003,9 @@ class ERPNextTestSuite(unittest.TestCase): def tearDown(self): frappe.db.rollback() + frappe.local.request_cache.clear() + if hasattr(frappe.local, "future_sle"): + frappe.local.future_sle.clear() def load_test_records(self, doctype): if doctype not in self.globalTestRecords: