fix(stock): value batch/serial return from ledger when original receipt has no bundle (backport #56631) (#56646)

fix(stock): value batch/serial return from ledger when original receipt has no bundle (#56631)

* fix(stock): value batch/serial return from ledger when original receipt has no bundle

* test(stock): add test to validate the valuation of serial/batch for return when original receipt has no bundle

(cherry picked from commit 6184c057db)

# Conflicts:
#	erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
This commit is contained in:
mergify[bot]
2026-06-30 15:34:51 +05:30
committed by GitHub
parent 01374db8da
commit 2c18c16be6
2 changed files with 136 additions and 1 deletions

View File

@@ -525,10 +525,12 @@ class SerialandBatchBundle(Document):
]
# Added to handle rejected warehouse case
return_warehouse = None
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
if self.warehouse in warehouses:
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
return_warehouse = self.warehouse
filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse])
bundle_data = frappe.get_all(
"Serial and Batch Bundle",
@@ -541,6 +543,11 @@ class SerialandBatchBundle(Document):
order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`",
)
if not bundle_data:
bundle_data = self.get_legacy_valuation_rate_for_return_entry(
return_against, return_against_voucher_detail_no, return_warehouse
)
if not bundle_data:
return {}
@@ -552,6 +559,49 @@ class SerialandBatchBundle(Document):
return valuation_details
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):
"""Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle."""
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if not (self.has_serial_no or self.has_batch_no):
return []
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(sle.serial_no, sle.batch_no, sle.incoming_rate)
.where(
(sle.voucher_no == return_against)
& (sle.voucher_detail_no == return_against_voucher_detail_no)
& (sle.item_code == self.item_code)
& (sle.is_cancelled == 0)
& (sle.serial_and_batch_bundle.isnull())
)
)
if return_warehouse:
query = query.where(sle.warehouse == return_warehouse)
data = []
for d in query.run(as_dict=True):
if d.serial_no:
for serial_no in get_serial_nos(d.serial_no):
data.append(
frappe._dict(
{"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
)
)
elif d.batch_no:
data.append(
frappe._dict(
{"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
)
)
return data
def calculate_total_qty(self, save=True):
self.total_qty = 0.0
for d in self.entries:

View File

@@ -1330,6 +1330,91 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
# Stock queue should have the returned stock: [[5, 100]]
self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100]])
def _assert_legacy_return_valuation(self, item_code, props, batch_no=None):
"""Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate."""
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
make_item(item_code, props)
if batch_no and not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
pr = make_purchase_receipt(
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
)
# Simulate a receipt migrated from an older version: serial nos / batch tracked via the
# deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle.
serial_nos = []
for row in pr.items:
if row.serial_and_batch_bundle:
serial_nos = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
pluck="serial_no",
)
frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle})
frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None)
serial_nos = [sn for sn in serial_nos if sn]
legacy = {"serial_and_batch_bundle": None}
if batch_no:
legacy["batch_no"] = batch_no
if serial_nos:
legacy["serial_no"] = "\n".join(serial_nos)
for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"):
frappe.db.set_value("Stock Ledger Entry", sle, legacy)
rt = make_return_doc("Purchase Receipt", pr.name)
rt.items[0].qty = -4
rt.items[0].received_qty = -4
rt.items[0].use_serial_batch_fields = 1
if batch_no:
rt.items[0].batch_no = batch_no
if serial_nos:
rt.items[0].serial_no = "\n".join(serial_nos[:4])
rt.submit()
difference_in_stock_value = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"},
"stock_value_difference",
)
# 4 units returned at the original ledger rate of 100 -> -400 (must not be zero)
self.assertEqual(flt(difference_in_stock_value, 2), -400.0)
def test_return_valuation_for_legacy_batch_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Batch Return Valuation",
{
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "LBRV-.#####",
"is_stock_item": 1,
},
batch_no="LBRV-BATCH-0001",
)
def test_return_valuation_for_legacy_serial_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Serial Return Valuation",
{"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1},
)
def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Serial Batch Return Valuation",
{
"has_serial_no": 1,
"serial_no_series": "LSBRV-.#####",
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "LSBRVB-.#####",
"is_stock_item": 1,
},
batch_no="LSBRV-BATCH-0001",
)
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos