From 11b82ba00822a883193f4a8d119da572ef9b19f5 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 4 Sep 2025 00:48:45 +0530 Subject: [PATCH 1/2] fix: non batch-wise valuation for batch item --- erpnext/stock/deprecated_serial_batch.py | 5 +++ .../serial_and_batch_bundle.py | 33 ++++++++++++++++++- erpnext/stock/serial_batch_bundle.py | 14 ++++---- erpnext/stock/stock_ledger.py | 9 +++++ erpnext/stock/utils.py | 1 + 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 7f233906a6e..6311a28d8ee 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -1,4 +1,5 @@ import datetime +import json from collections import defaultdict import frappe @@ -226,6 +227,9 @@ class DeprecatedBatchNoValuation: ) def set_balance_value_for_non_batchwise_valuation_batches(self): self.last_sle = self.get_last_sle_for_non_batch() + if self.last_sle and self.last_sle.stock_value: + self.stock_queue = json.loads(self.last_sle.stock_queue or "[]") or [] + self.set_balance_value_from_sl_entries() self.set_balance_value_from_bundle() @@ -305,6 +309,7 @@ class DeprecatedBatchNoValuation: .select( sle.stock_value, sle.qty_after_transaction, + sle.stock_queue, ) .where( (sle.item_code == self.sle.item_code) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 8bc19a1ef63..fd268701544 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -3,6 +3,7 @@ import collections import csv +import json from collections import Counter, defaultdict import frappe @@ -29,6 +30,7 @@ from erpnext.stock.serial_batch_bundle import ( get_batches_from_bundle, ) from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle +from erpnext.stock.valuation import FIFOValuation class SerialNoExistsInFutureTransactionError(frappe.ValidationError): @@ -467,6 +469,8 @@ class SerialandBatchBundle(Document): ) def set_incoming_rate_for_outward_transaction(self, row=None, save=False, allow_negative_stock=False): + from erpnext.stock.utils import get_valuation_method + sle = self.get_sle_for_outward_transaction() if self.has_serial_no: @@ -483,13 +487,40 @@ class SerialandBatchBundle(Document): warehouse=self.warehouse, ) + stock_queue = [] + if hasattr(sn_obj, "stock_queue") and sn_obj.stock_queue: + stock_queue = parse_json(sn_obj.stock_queue) + + val_method = get_valuation_method(self.item_code) + for d in self.entries: available_qty = 0 if self.has_serial_no: d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0)) else: - d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) + actual_qty = d.qty + if ( + stock_queue + and val_method == "FIFO" + and d.batch_no in sn_obj.non_batchwise_valuation_batches + ): + if actual_qty < 0: + stock_queue = FIFOValuation(stock_queue) + _prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value() + + stock_queue.remove_stock(qty=abs(actual_qty)) + _qty, stock_value = stock_queue.get_total_stock_and_value() + + stock_value_difference = stock_value - prev_stock_value + d.incoming_rate = abs(flt(stock_value_difference) / abs(flt(actual_qty))) + stock_queue = stock_queue.state + else: + d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) + stock_queue.append([d.qty, d.incoming_rate]) + d.stock_queue = json.dumps(stock_queue) + else: + d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) available_qty = flt(sn_obj.available_qty.get(d.batch_no), d.precision("qty")) if self.docstatus == 1: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index bb23ee3e584..14961d57ac5 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -708,6 +708,7 @@ class BatchNoValuation(DeprecatedBatchNoValuation): for key, value in kwargs.items(): setattr(self, key, value) + self.stock_queue = [] self.batch_nos = self.get_batch_nos() self.prepare_batches() self.calculate_avg_rate() @@ -804,15 +805,12 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.non_batchwise_valuation_batches = self.batches return - if get_valuation_method(self.sle.item_code) == "FIFO": - self.batchwise_valuation_batches = self.batches - else: - batches = frappe.get_all( - "Batch", filters={"name": ("in", self.batches), "use_batchwise_valuation": 1}, fields=["name"] - ) + batches = frappe.get_all( + "Batch", filters={"name": ("in", self.batches), "use_batchwise_valuation": 1}, fields=["name"] + ) - for batch in batches: - self.batchwise_valuation_batches.append(batch.name) + for batch in batches: + self.batchwise_valuation_batches.append(batch.name) self.non_batchwise_valuation_batches = list(set(self.batches) - set(self.batchwise_valuation_batches)) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 985ef099c6a..ef975195e2b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1046,6 +1046,15 @@ class update_entries_after: doc.set_incoming_rate(save=True, allow_negative_stock=self.allow_negative_stock) doc.calculate_qty_and_amount(save=True) + if stock_queue := frappe.get_all( + "Serial and Batch Entry", + filters={"parent": sle.serial_and_batch_bundle, "stock_queue": ("is", "set")}, + pluck="stock_queue", + order_by="idx desc", + limit=1, + ): + self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else [] + self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount) self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision) if flt(self.wh_data.qty_after_transaction, self.flt_precision): diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 78fd3ca2a75..0656cd6bb41 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -373,6 +373,7 @@ def get_avg_purchase_rate(serial_nos): ) +@frappe.request_cache def get_valuation_method(item_code): """get valuation method from item or default""" val_method = frappe.get_cached_value("Item", item_code, "valuation_method") From ac8637d5a0a2c7312be1e368166d5b55e182f445 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 4 Sep 2025 07:18:03 +0530 Subject: [PATCH 2/2] chore: fix test case --- erpnext/stock/deprecated_serial_batch.py | 2 +- .../test_serial_and_batch_bundle.py | 105 ++++++++++-------- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 6311a28d8ee..4adc5449dba 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -227,7 +227,7 @@ class DeprecatedBatchNoValuation: ) def set_balance_value_for_non_batchwise_valuation_batches(self): self.last_sle = self.get_last_sle_for_non_batch() - if self.last_sle and self.last_sle.stock_value: + if self.last_sle and self.last_sle.stock_queue: self.stock_queue = json.loads(self.last_sle.stock_queue or "[]") or [] self.set_balance_value_from_sl_entries() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2a67ecb983b..c0dbecfcba3 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -203,7 +203,10 @@ class TestSerialandBatchBundle(IntegrationTestCase): batch_item_code, { "has_batch_no": 1, + "batch_number_series": "TEST-OLD-BAT-VAL-.#####", + "create_new_batch": 1, "is_stock_item": 1, + "valuation_method": "FIFO", }, ) @@ -256,57 +259,63 @@ class TestSerialandBatchBundle(IntegrationTestCase): doc.submit() doc.reload() - bundle_doc = make_serial_batch_bundle( - { - "item_code": batch_item_code, - "warehouse": "_Test Warehouse - _TC", - "voucher_type": "Stock Entry", - "posting_date": today(), - "posting_time": nowtime(), - "qty": -10, - "batches": frappe._dict({batch_id: 10}), - "type_of_transaction": "Outward", - "do_not_submit": True, - } - ) - - bundle_doc.reload() - for row in bundle_doc.entries: - self.assertEqual(flt(row.stock_value_difference, 2), -1666.67) - - bundle_doc.flags.ignore_permissions = True - bundle_doc.flags.ignore_mandatory = True - bundle_doc.flags.ignore_links = True - bundle_doc.flags.ignore_validate = True - bundle_doc.submit() - - bundle_doc = make_serial_batch_bundle( - { - "item_code": batch_item_code, - "warehouse": "_Test Warehouse - _TC", - "voucher_type": "Stock Entry", - "posting_date": today(), - "posting_time": nowtime(), - "qty": -20, - "batches": frappe._dict({batch_id: 20}), - "type_of_transaction": "Outward", - "do_not_submit": True, - } - ) - - bundle_doc.reload() - for row in bundle_doc.entries: - self.assertEqual(flt(row.stock_value_difference, 2), -3333.33) - - bundle_doc.flags.ignore_permissions = True - bundle_doc.flags.ignore_mandatory = True - bundle_doc.flags.ignore_links = True - bundle_doc.flags.ignore_validate = True - bundle_doc.submit() - frappe.flags.ignore_serial_batch_bundle_validation = False frappe.flags.use_serial_and_batch_fields = False + se = make_stock_entry( + item_code=batch_item_code, + source="_Test Warehouse - _TC", + qty=10, + use_serial_batch_fields=True, + batch_no=batch_id, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": se.name}, + ["stock_value_difference", "stock_queue"], + as_dict=True, + ) + + self.assertEqual(flt(sle.stock_value_difference), 1000.00 * -1) + self.assertEqual(json.loads(sle.stock_queue), [[20, 200]]) + + se = make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": se.name}, + ["stock_value_difference", "stock_queue"], + as_dict=True, + ) + + self.assertEqual(flt(sle.stock_value_difference), 1000.00) + self.assertEqual(json.loads(sle.stock_queue), [[20, 200]]) + + se = make_stock_entry( + item_code=batch_item_code, + source="_Test Warehouse - _TC", + qty=30, + use_serial_batch_fields=False, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": se.name}, + ["stock_value_difference", "stock_queue", "stock_value"], + as_dict=True, + ) + + self.assertEqual(flt(sle.stock_value_difference), 5000.00 * -1) + self.assertFalse(json.loads(sle.stock_queue or "[]")) + self.assertEqual(flt(sle.stock_value), 0.0) + def test_old_serial_no_valuation(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt