fix: negative stock issue for higher precision

(cherry picked from commit 87be020c78)

# Conflicts:
#	erpnext/stock/doctype/delivery_note/test_delivery_note.py
(cherry picked from commit 1bbeecff12)

# Conflicts:
#	erpnext/stock/doctype/delivery_note/test_delivery_note.py
This commit is contained in:
Rohit Waghchaure
2026-01-08 13:39:03 +05:30
committed by Mergify
parent 6267b9aea5
commit 21d13859a0
2 changed files with 638 additions and 1 deletions

View File

@@ -1507,6 +1507,639 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(stock_value_difference, 100.0 * 5)
<<<<<<< HEAD
=======
def test_delivery_note_return_valuation_without_use_serial_batch_field(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
batch_item = make_item(
"_Test Delivery Note Return Valuation Batch Item",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "BRTN-DNN-BI-.#####",
},
).name
serial_item = make_item(
"_Test Delivery Note Return Valuation Serial Item",
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "SRTN-DNN-TP-.#####"},
).name
batches = {}
serial_nos = []
for qty, rate in {3: 300, 2: 100}.items():
se = make_stock_entry(
item_code=batch_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate
)
batches[get_batch_from_bundle(se.items[0].serial_and_batch_bundle)] = qty
for qty, rate in {2: 100, 1: 50}.items():
make_stock_entry(item_code=serial_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate)
serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle))
dn = create_delivery_note(
item_code=batch_item,
qty=5,
rate=1000,
use_serial_batch_fields=0,
batches=batches,
do_not_submit=True,
)
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": serial_item,
"warehouse": dn.items[0].warehouse,
"qty": 3,
"voucher_type": "Delivery Note",
"serial_nos": serial_nos,
"posting_date": dn.posting_date,
"posting_time": dn.posting_time,
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
).name
dn.append(
"items",
{
"item_code": serial_item,
"qty": 3,
"rate": 700,
"base_rate": 700,
"item_name": serial_item,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 0,
"serial_and_batch_bundle": bundle_id,
},
)
dn.save()
dn.submit()
dn.reload()
batch_no_valuation = defaultdict(float)
serial_no_valuation = defaultdict(float)
for row in dn.items:
if row.serial_and_batch_bundle:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no", "batch_no"],
)
for d in bundle_data:
if d.batch_no:
batch_no_valuation[d.batch_no] = d.incoming_rate
elif d.serial_no:
serial_no_valuation[d.serial_no] = d.incoming_rate
return_entry = make_sales_return(dn.name)
return_entry.save()
return_entry.submit()
return_entry.reload()
for row in return_entry.items:
if row.item_code == batch_item:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "batch_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no])
else:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, serial_no_valuation[d.serial_no])
def test_delivery_note_return_valuation_with_use_serial_batch_field(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
batch_item = make_item(
"_Test Delivery Note Return Valuation WITH Batch Item",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "BRTN-DNN-BIW-.#####",
},
).name
serial_item = make_item(
"_Test Delivery Note Return Valuation WITH Serial Item",
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "SRTN-DNN-TPW-.#####"},
).name
batches = []
serial_nos = []
for qty, rate in {3: 300, 2: 100}.items():
se = make_stock_entry(
item_code=batch_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate
)
batches.append(get_batch_from_bundle(se.items[0].serial_and_batch_bundle))
for qty, rate in {2: 100, 1: 50}.items():
se = make_stock_entry(
item_code=serial_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate
)
serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle))
dn = create_delivery_note(
item_code=batch_item,
qty=3,
rate=1000,
use_serial_batch_fields=1,
batch_no=batches[0],
do_not_submit=True,
)
dn.append(
"items",
{
"item_code": batch_item,
"qty": 2,
"rate": 1000,
"base_rate": 1000,
"item_name": batch_item,
"uom": dn.items[0].uom,
"stock_uom": dn.items[0].uom,
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 1,
"batch_no": batches[1],
},
)
dn.append(
"items",
{
"item_code": serial_item,
"qty": 2,
"rate": 700,
"base_rate": 700,
"item_name": serial_item,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 1,
"serial_no": "\n".join(serial_nos[0:2]),
},
)
dn.append(
"items",
{
"item_code": serial_item,
"qty": 1,
"rate": 700,
"base_rate": 700,
"item_name": serial_item,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 1,
"serial_no": serial_nos[-1],
},
)
dn.save()
dn.submit()
dn.reload()
batch_no_valuation = defaultdict(float)
serial_no_valuation = defaultdict(float)
for row in dn.items:
if row.serial_and_batch_bundle:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no", "batch_no"],
)
for d in bundle_data:
if d.batch_no:
batch_no_valuation[d.batch_no] = d.incoming_rate
elif d.serial_no:
serial_no_valuation[d.serial_no] = d.incoming_rate
return_entry = make_sales_return(dn.name)
return_entry.save()
return_entry.submit()
return_entry.reload()
for row in return_entry.items:
if row.item_code == batch_item:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "batch_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no])
else:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, serial_no_valuation[d.serial_no])
def test_auto_set_serial_batch_for_draft_dn(self):
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", "FIFO")
batch_item = make_item(
"_Test Auto Set Serial Batch Draft DN",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "TAS-BASD-.#####",
},
)
serial_item = make_item(
"_Test Auto Set Serial Batch Draft DN Serial Item",
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TAS-SASD-.#####"},
)
batch_serial_item = make_item(
"_Test Auto Set Serial Batch Draft DN Batch Serial Item",
properties={
"has_batch_no": 1,
"has_serial_no": 1,
"is_stock_item": 1,
"create_new_batch": 1,
"batch_number_series": "TAS-BSD-.#####",
"serial_no_series": "TAS-SSD-.#####",
},
)
for item in [batch_item, serial_item, batch_serial_item]:
make_stock_entry(item_code=item.name, target="_Test Warehouse - _TC", qty=5, basic_rate=100)
dn = create_delivery_note(
item_code=batch_item,
qty=5,
rate=500,
use_serial_batch_fields=1,
do_not_submit=True,
)
for item in [serial_item, batch_serial_item]:
dn.append(
"items",
{
"item_code": item.name,
"qty": 5,
"rate": 500,
"base_rate": 500,
"item_name": item.name,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"warehouse": dn.items[0].warehouse,
"use_serial_batch_fields": 1,
},
)
dn.save()
for row in dn.items:
if row.item_code == batch_item.name:
self.assertTrue(row.batch_no)
if row.item_code == serial_item.name:
self.assertTrue(row.serial_no)
def test_delivery_note_return_for_batch_item_with_different_warehouse(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
batch_item = make_item(
"_Test Delivery Note Return Valuation WITH Batch Item",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"is_stock_item": 1,
"batch_number_series": "BRTN-DNN-BIW-.#####",
},
).name
batches = []
for qty, rate in {5: 300}.items():
se = make_stock_entry(
item_code=batch_item, target="_Test Warehouse - _TC", qty=qty, basic_rate=rate
)
batches.append(get_batch_from_bundle(se.items[0].serial_and_batch_bundle))
warehouse = create_warehouse("Sales Return Test Warehouse 1", company="_Test Company")
dn = create_delivery_note(
item_code=batch_item,
qty=5,
rate=1000,
use_serial_batch_fields=1,
batch_no=batches[0],
do_not_submit=True,
)
self.assertEqual(dn.items[0].warehouse, "_Test Warehouse - _TC")
dn.save()
dn.submit()
dn.reload()
batch_no_valuation = defaultdict(float)
for row in dn.items:
if row.serial_and_batch_bundle:
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "serial_no", "batch_no"],
)
for d in bundle_data:
if d.batch_no:
batch_no_valuation[d.batch_no] = d.incoming_rate
return_entry = make_sales_return(dn.name)
return_entry.items[0].warehouse = warehouse
return_entry.save()
return_entry.submit()
return_entry.reload()
for row in return_entry.items:
self.assertEqual(row.warehouse, warehouse)
bundle_data = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
fields=["incoming_rate", "batch_no"],
)
for d in bundle_data:
self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no])
def test_delivery_note_per_billed_after_return(self):
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
so = make_sales_order(qty=2)
dn = make_delivery_note(so.name)
dn.submit()
self.assertEqual(dn.per_billed, 0)
self.assertEqual(dn.status, "To Bill")
si = make_sales_invoice(dn.name)
si.location = "Test Location"
si.submit()
dn_return = create_delivery_note(is_return=1, return_against=dn.name, qty=-2, do_not_submit=True)
dn_return.items[0].dn_detail = dn.items[0].name
dn_return.submit()
returned = frappe.get_doc("Delivery Note", dn_return.name)
returned.update_prevdoc_status()
dn.load_from_db()
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
from erpnext.stock.doctype.item.test_item import make_item
rm_items = []
for item_code, properties in {
"_Packed Service Item": {"is_stock_item": 0},
"_Packed FG Item New 1": {
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SN-PACKED-1-.#####",
},
"_Packed FG Item New 2": {
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BATCH-PACKED-2-.#####",
},
"_Packed FG Item New 3": {
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BATCH-PACKED-3-.#####",
"has_serial_no": 1,
"serial_no_series": "SN-PACKED-3-.#####",
},
}.items():
if not frappe.db.exists("Item", item_code):
make_item(item_code, properties)
if item_code != "_Packed Service Item":
rm_items.append(item_code)
for rate in [100, 200]:
make_stock_entry(item=item_code, target="_Test Warehouse - _TC", qty=5, rate=rate)
make_product_bundle("_Packed Service Item", rm_items)
dn = create_delivery_note(
item_code="_Packed Service Item",
warehouse="_Test Warehouse - _TC",
qty=5,
)
dn.reload()
serial_batch_map = {}
for row in dn.packed_items:
self.assertTrue(row.serial_and_batch_bundle)
if row.item_code not in serial_batch_map:
serial_batch_map[row.item_code] = frappe._dict(
{
"serial_nos": [],
"batches": defaultdict(int),
"serial_no_valuation": defaultdict(float),
"batch_no_valuation": defaultdict(float),
}
)
doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for entry in doc.entries:
if entry.serial_no:
serial_batch_map[row.item_code].serial_nos.append(entry.serial_no)
serial_batch_map[row.item_code].serial_no_valuation[entry.serial_no] = entry.incoming_rate
if entry.batch_no:
serial_batch_map[row.item_code].batches[entry.batch_no] += entry.qty
serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no] = entry.incoming_rate
dn1 = make_sales_return(dn.name)
dn1.items[0].qty = -2
dn1.submit()
dn1.reload()
for row in dn1.packed_items:
doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for entry in doc.entries:
if entry.serial_no:
self.assertTrue(entry.serial_no in serial_batch_map[row.item_code].serial_nos)
self.assertEqual(
entry.incoming_rate,
serial_batch_map[row.item_code].serial_no_valuation[entry.serial_no],
)
serial_batch_map[row.item_code].serial_nos.remove(entry.serial_no)
serial_batch_map[row.item_code].serial_no_valuation.pop(entry.serial_no)
elif entry.batch_no:
serial_batch_map[row.item_code].batches[entry.batch_no] += entry.qty
self.assertTrue(entry.batch_no in serial_batch_map[row.item_code].batches)
self.assertEqual(entry.qty, 2.0)
self.assertEqual(
entry.incoming_rate,
serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no],
)
dn2 = make_sales_return(dn.name)
dn2.items[0].qty = -3
dn2.submit()
dn2.reload()
for row in dn2.packed_items:
doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for entry in doc.entries:
if entry.serial_no:
self.assertTrue(entry.serial_no in serial_batch_map[row.item_code].serial_nos)
self.assertEqual(
entry.incoming_rate,
serial_batch_map[row.item_code].serial_no_valuation[entry.serial_no],
)
serial_batch_map[row.item_code].serial_nos.remove(entry.serial_no)
serial_batch_map[row.item_code].serial_no_valuation.pop(entry.serial_no)
elif entry.batch_no:
serial_batch_map[row.item_code].batches[entry.batch_no] += entry.qty
self.assertEqual(serial_batch_map[row.item_code].batches[entry.batch_no], 0.0)
self.assertTrue(entry.batch_no in serial_batch_map[row.item_code].batches)
self.assertEqual(entry.qty, 3.0)
self.assertEqual(
entry.incoming_rate,
serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no],
)
<<<<<<< HEAD
@change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
def test_partial_delivery_note_against_reserved_stock(self):
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_stock_reservation_entries_for_voucher,
)
# create batch item
batch_item = make_item(
"_Test Batch Item For DN Reserve Check",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBDNR.#####",
},
)
serial_item = make_item(
"_Test Serial Item For DN Reserve Check",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TSNDNR.#####",
},
)
company = "_Test Company"
warehouse = create_warehouse("Test Partial DN Reserved Stock", company=company)
customer = "_Test Customer"
items = [batch_item.name, serial_item.name]
for idx, item in enumerate(items):
# make inward entry for batch item
se = make_stock_entry(item_code=item, purpose="Material Receipt", qty=10, to_warehouse=warehouse)
sabb = se.items[0].serial_and_batch_bundle
batch_no = get_batch_from_bundle(sabb) if not idx else None
serial_nos = get_serial_nos_from_bundle(sabb) if idx else None
# make sales order and reserve the quantites against the so
so = make_sales_order(item_code=item, qty=10, rate=100, customer=customer, warehouse=warehouse)
so.submit()
so.create_stock_reservation_entries()
so.reload()
# create a delivery note with partial quantity from resreved quantity
dn = create_dn_against_so(so=so.name, delivered_qty=5, do_not_submit=True)
dn.items[0].use_serial_batch_fields = 1
if batch_no:
dn.items[0].batch_no = batch_no
else:
dn.items[0].serial_no = "\n".join(serial_nos[:5])
dn.save()
dn.submit()
against_sales_order = dn.items[0].against_sales_order
so_detail = dn.items[0].so_detail
sre_details = get_stock_reservation_entries_for_voucher(
so.doctype, against_sales_order, so_detail, ["reserved_qty", "delivered_qty", "status"]
)
# check partially delivered reserved stock
self.assertEqual(sre_details[0].status, "Partially Delivered")
self.assertEqual(sre_details[0].reserved_qty, so.items[0].qty)
self.assertEqual(sre_details[0].delivered_qty, dn.items[0].qty)
=======
def test_negative_stock_with_higher_precision(self):
original_flt_precision = frappe.db.get_default("float_precision")
frappe.db.set_single_value("System Settings", "float_precision", 7)
item_code = make_item(
"Test Negative Stock High Precision Item", properties={"is_stock_item": 1, "valuation_rate": 1}
).name
dn = create_delivery_note(
item_code=item_code,
qty=0.0000010,
do_not_submit=True,
)
self.assertRaises(frappe.ValidationError, dn.submit)
frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision)
>>>>>>> 87be020c78 (fix: negative stock issue for higher precision)
>>>>>>> 1bbeecff12 (fix: negative stock issue for higher precision)
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")

View File

@@ -715,7 +715,11 @@ class update_entries_after:
diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty)
diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001:
diff_threshold = 0.0001
if self.flt_precision > 4:
diff_threshold = 10 ** (-1 * self.flt_precision)
if diff < 0 and abs(diff) > diff_threshold:
# negative stock!
exc = sle.copy().update({"diff": diff})
self.exceptions.setdefault(sle.warehouse, []).append(exc)