From 9261c9b47f1d1ae459294b51846168ed5c7d8056 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 2 Sep 2026 11:42:37 +0530 Subject: [PATCH] fix(stock): assign batch_no only when the first batch covers the full qty (#58668) * fix(stock): assign batch_no only when the first batch covers the full qty The auto-pick loop reduced the requested qty per batch and left the last visited batch on the row, so a qty spanning batches got a batch that could not fulfil it and failed at submit with a misleading negative-stock error. Assign the first batch in pick order only when it alone covers the qty. Otherwise leave batch_no empty so the auto-created Serial and Batch Bundle splits the qty across batches at submit. Batches are queried without qty so filter_batches subtracts rows already in the document from the uncapped batch quantities. Querying on a copy also stops get_auto_batch_nos from clearing warehouse on the kwargs later used to pick serial nos. Fixes #58640 * test(stock): cover batch auto-pick when qty spans batches * fix(stock): pick serial nos across batches when no batch covers the qty With batch_no left empty for a qty that spans batches, the serial pick for a serialised and batched item filtered on [None] and returned nothing, leaving the row with neither identity. Skip the batch filter when there is no batch so the serial nos are picked in the configured order across batches; the bundle built at submit derives each serial's batch. * test(stock): cover serial pick across batches for batched serial items --- erpnext/stock/get_item_details.py | 38 +++++---- erpnext/stock/tests/test_get_item_details.py | 90 ++++++++++++++++++++ 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 8a53f976bdc..0dfa103da92 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -292,7 +292,6 @@ def set_valuation_rate(out: frappe._dict, ctx: frappe._dict): def update_stock(ctx, out, doc=None): - from erpnext.stock.doctype.batch.batch import get_available_batches from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward if ( @@ -325,32 +324,21 @@ def update_stock(ctx, out, doc=None): if ctx.get("ignore_serial_nos"): kwargs["ignore_serial_nos"] = ctx.get("ignore_serial_nos") - qty = out.stock_qty - batches = [] if out.has_batch_no and not ctx.get("batch_no"): - batches = get_available_batches(kwargs) - if doc: - filter_batches(batches, doc) - - for batch_no, batch_qty in batches.items(): + batch_no = get_batch_no_covering_qty(kwargs, doc, out.stock_qty) + if batch_no: + out.update({"batch_no": batch_no, "actual_batch_qty": out.stock_qty}) rate = get_batch_based_item_price( {"price_list": doc.get("selling_price_list"), "uom": out.uom, "batch_no": batch_no}, out.item_code, ) - if batch_qty >= qty: - out.update({"batch_no": batch_no, "actual_batch_qty": qty}) - if rate: - out.update({"rate": rate, "price_list_rate": rate}) - break - else: - qty -= batch_qty - - out.update({"batch_no": batch_no, "actual_batch_qty": batch_qty}) if rate: out.update({"rate": rate, "price_list_rate": rate}) if out.has_serial_no and out.has_batch_no and has_incorrect_serial_nos(ctx, out): - kwargs["batches"] = [ctx.get("batch_no")] if ctx.get("batch_no") else [out.get("batch_no")] + batch_no = ctx.get("batch_no") or out.get("batch_no") + if batch_no: + kwargs["batches"] = [batch_no] serial_nos = get_serial_nos_for_outward(kwargs) serial_nos = get_filtered_serial_nos(serial_nos, doc) @@ -376,6 +364,20 @@ def has_incorrect_serial_nos(ctx, out): return False +def get_batch_no_covering_qty(kwargs, doc, qty): + from erpnext.stock.doctype.batch.batch import get_available_batches + + batches = get_available_batches(frappe._dict(kwargs, qty=0)) + if doc: + filter_batches(batches, doc) + + batch_no = next(iter(batches), None) + if batch_no and flt(batches[batch_no]) >= flt(qty): + return batch_no + + return None + + def filter_batches(batches, doc): for row in doc.get("items"): if row.get("batch_no") in batches: diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index e36afeabfea..f0e5f81ea8a 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -457,3 +457,93 @@ class TestGetItemDetail(ERPNextTestSuite): frappe.set_user("Administrator") frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) frappe.clear_cache(doctype="Buying Settings") + + def make_batched_item_with_stock(self, quantities, uoms=None, **properties): + 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_entry.test_stock_entry import make_stock_entry + + properties.update({"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "FBQ-.#####"}) + item_code = make_item(properties=properties, uoms=uoms).name + batches = [] + for qty in quantities: + se = make_stock_entry( + item_code=item_code, target="_Test Warehouse - _TC", qty=qty, basic_rate=100 + ) + batches.append(get_batch_from_bundle(se.items[0].serial_and_batch_bundle)) + + return item_code, batches + + def get_item_details_for_row(self, item_code, qty, items=None): + ctx = frappe._dict( + { + "doctype": "Delivery Note", + "item_code": item_code, + "company": "_Test Company", + "warehouse": "_Test Warehouse - _TC", + "qty": qty, + "use_serial_batch_fields": 1, + "price_list": "_Test Price List", + "currency": "INR", + "conversion_rate": 1.0, + "price_list_currency": "INR", + "plc_conversion_rate": 1.0, + "ignore_pricing_rule": 1, + } + ) + doc = {"doctype": "Delivery Note", "selling_price_list": "_Test Price List", "items": items or []} + return get_item_details(ctx, doc=doc) + + def get_picked_batch_no(self, item_code, qty, items=None): + return self.get_item_details_for_row(item_code, qty, items).get("batch_no") + + def test_batch_no_set_only_when_first_batch_covers_qty(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code, batches = self.make_batched_item_with_stock([2, 3, 14]) + first_batch_row = [{"batch_no": batches[0], "qty": 2, "stock_qty": 2}] + + with self.change_settings( + "Stock Settings", + {"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1}, + ): + self.assertEqual(self.get_picked_batch_no(item_code, 2), batches[0]) + self.assertEqual(self.get_picked_batch_no(item_code, 3, items=first_batch_row), batches[1]) + self.assertIsNone(self.get_picked_batch_no(item_code, 5)) + self.assertIsNone(self.get_picked_batch_no(item_code, 20)) + + dn = create_delivery_note(item_code=item_code, qty=5, use_serial_batch_fields=1) + dn.reload() + entries = frappe.get_all( + "Serial and Batch Entry", {"parent": dn.items[0].serial_and_batch_bundle}, ["batch_no", "qty"] + ) + self.assertEqual({d.batch_no: d.qty for d in entries}, {batches[0]: -2, batches[1]: -3}) + + def test_serial_nos_picked_across_batches_when_no_batch_covers_qty(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_serial_nos_from_bundle, + ) + + item_code, batches = self.make_batched_item_with_stock( + [2, 3], has_serial_no=1, serial_no_series="FBQ-SN-.#####" + ) + + with self.change_settings( + "Stock Settings", + {"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1}, + ): + details = self.get_item_details_for_row(item_code, 5) + self.assertIsNone(details.get("batch_no")) + serial_nos = details.serial_no.split("\n") + self.assertEqual(len(serial_nos), 5) + + dn = create_delivery_note( + item_code=item_code, qty=5, use_serial_batch_fields=1, serial_no=details.serial_no + ) + dn.reload() + self.assertEqual( + get_serial_nos_from_bundle(dn.items[0].serial_and_batch_bundle), sorted(serial_nos) + )