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
This commit is contained in:
Mihir Kandoi
2026-09-02 11:42:37 +05:30
committed by GitHub
parent 0230879501
commit 9261c9b47f
2 changed files with 110 additions and 18 deletions

View File

@@ -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:

View File

@@ -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)
)