mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-03 08:32:24 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user