From 21d13859a0c3bdff789f46eda0e1f079e60808c9 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 8 Jan 2026 13:39:03 +0530 Subject: [PATCH] fix: negative stock issue for higher precision (cherry picked from commit 87be020c783f99f21dbbdcb653f538b97431ebdd) # Conflicts: # erpnext/stock/doctype/delivery_note/test_delivery_note.py (cherry picked from commit 1bbeecff12ee22efccf0c1e8583d52aecd78bbb7) # Conflicts: # erpnext/stock/doctype/delivery_note/test_delivery_note.py --- .../delivery_note/test_delivery_note.py | 633 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 6 +- 2 files changed, 638 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 507fb78663e..4e0cebc3f3d 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -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") diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 68c7c314091..5be94cc799e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -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)