diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json index c7ef426d659..ff2341234da 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json @@ -109,7 +109,8 @@ "in_list_view": 1, "label": "Voucher Type", "options": "DocType", - "reqd": 1 + "reqd": 1, + "search_index": 1 }, { "fieldname": "voucher_no", @@ -196,8 +197,7 @@ "fieldtype": "Select", "label": "Type of Transaction", "options": "\nInward\nOutward\nMaintenance\nAsset Repair", - "reqd": 1, - "search_index": 1 + "reqd": 1 }, { "default": "0", @@ -264,7 +264,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-05-30 18:05:55.489195", + "modified": "2025-09-15 14:37:26.441742", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Bundle", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index fd268701544..e99d456ca19 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -119,8 +119,8 @@ class SerialandBatchBundle(Document): self.allow_existing_serial_nos() if not self.flags.ignore_validate_serial_batch or frappe.in_test: self.validate_serial_nos_duplicate() - self.check_future_entries_exists() + self.check_future_entries_exists() self.set_is_outward() self.calculate_total_qty() self.set_warehouse() @@ -229,7 +229,7 @@ class SerialandBatchBundle(Document): return if self.voucher_type == "Stock Reconciliation": - serial_nos = self.get_serial_nos_for_validate() + serial_nos, batches = self.get_serial_nos_for_validate() else: serial_nos = [d.serial_no for d in self.entries if d.serial_no] @@ -720,15 +720,22 @@ class SerialandBatchBundle(Document): if self.flags and self.flags.via_landed_cost_voucher: return - if not self.has_serial_no: - return + serial_nos = [] + batches = [] if self.voucher_type == "Stock Reconciliation": - serial_nos = self.get_serial_nos_for_validate(is_cancelled=is_cancelled) + serial_nos, batches = self.get_serial_nos_for_validate(is_cancelled=is_cancelled) else: + batches = [d.batch_no for d in self.entries if d.batch_no] + + if ( + self.voucher_type != "Stock Reconciliation" + and not self.flags.ignore_validate_serial_batch + and self.has_serial_no + ): serial_nos = [d.serial_no for d in self.entries if d.serial_no] - if not serial_nos: + if self.has_batch_no and not self.has_serial_no and not batches: return parent = frappe.qb.DocType("Serial and Batch Bundle") @@ -744,65 +751,117 @@ class SerialandBatchBundle(Document): .on(parent.name == child.parent) .select( child.serial_no, + child.batch_no, parent.voucher_type, parent.voucher_no, ) .where( - (child.serial_no.isin(serial_nos)) - & (child.parent != self.name) + (child.parent != self.name) & (parent.item_code == self.item_code) & (parent.docstatus == 1) & (parent.is_cancelled == 0) & (parent.type_of_transaction.isin(["Inward", "Outward"])) ) .where(timestamp_condition) - ).run(as_dict=True) + ) + + if self.has_batch_no and not self.has_serial_no: + future_entries = future_entries.where(parent.voucher_type == "Stock Reconciliation") + + if serial_nos: + future_entries = future_entries.where( + (child.serial_no.isin(serial_nos)) + | ((parent.warehouse == self.warehouse) & (parent.voucher_type == "Stock Reconciliation")) + ) + elif self.has_serial_no: + future_entries = future_entries.where( + (parent.warehouse == self.warehouse) & (parent.voucher_type == "Stock Reconciliation") + ) + elif batches: + future_entries = future_entries.where(child.batch_no.isin(batches)) + + future_entries = future_entries.run(as_dict=True) if future_entries: - msg = """The serial nos has been used in the future - transactions so you need to cancel them first. - The list of serial nos and their respective - transactions are as below.""" + if self.has_serial_no: + title = "Serial No Exists In Future Transaction(s)" + else: + title = "Batches Exists In Future Transaction(s)" + + msg = """Since the stock reconciliation exists + for future dates, cancel it first. For Serial/Batch, + if you want to make a backdated transaction, + avoid using stock reconciliation. + For more details about the transaction, + please refer to the list below. + """ msg += "

" - title = "Serial No Exists In Future Transaction(s)" - frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError) def get_serial_nos_for_validate(self, is_cancelled=False): serial_nos = [d.serial_no for d in self.entries if d.serial_no] - skip_serial_nos = self.get_skip_serial_nos_for_stock_reconciliation(is_cancelled=is_cancelled) - serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos))) + batches = [d.batch_no for d in self.entries if d.batch_no] - return serial_nos + skip_serial_nos, skip_batches = self.get_skip_serial_nos_for_stock_reconciliation( + is_cancelled=is_cancelled + ) + + serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos))) + batch_nos = list(set(sorted(batches)) - set(sorted(skip_batches))) + + return serial_nos, batch_nos def get_skip_serial_nos_for_stock_reconciliation(self, is_cancelled=False): data = get_stock_reco_details(self.voucher_detail_no) + if not data: - return [] + return [], [] + + current_serial_nos = set() + serial_nos = set() + current_batches = set() + batches = set() if data.current_serial_no: current_serial_nos = set(parse_serial_nos(data.current_serial_no)) serial_nos = set(parse_serial_nos(data.serial_no)) if data.serial_no else set([]) - return list(serial_nos.intersection(current_serial_nos)) + return list(serial_nos.intersection(current_serial_nos)), [] + + elif data.batch_no and data.current_qty == data.qty: + return [], [data.batch_no] + elif data.current_serial_and_batch_bundle: - current_serial_nos = set(get_serial_nos_from_bundle(data.current_serial_and_batch_bundle)) + if self.has_serial_no: + current_serial_nos = set(get_serial_nos_from_bundle(data.current_serial_and_batch_bundle)) + else: + current_batches = set(get_batches_from_bundle(data.current_serial_and_batch_bundle)) + if is_cancelled: - return current_serial_nos + return list(current_serial_nos), list(current_batches) - serial_nos = ( - set(get_serial_nos_from_bundle(data.serial_and_batch_bundle)) - if data.serial_and_batch_bundle - else set([]) + if self.has_serial_no: + serial_nos = ( + set(get_serial_nos_from_bundle(data.serial_and_batch_bundle)) + if data.serial_and_batch_bundle + else set([]) + ) + elif self.has_batch_no and data.serial_and_batch_bundle: + batches = set(get_batches_from_bundle(data.serial_and_batch_bundle)) + + return list(serial_nos.intersection(current_serial_nos)), list( + batches.intersection(current_batches) ) - return list(serial_nos.intersection(current_serial_nos)) - return [] + return [], [] def reset_qty(self, row, qty_field=None): qty_field = self.get_qty_field(row, qty_field=qty_field) @@ -2793,6 +2852,14 @@ def get_stock_reco_details(voucher_detail_no): return frappe.db.get_value( "Stock Reconciliation Item", voucher_detail_no, - ["current_serial_no", "serial_no", "serial_and_batch_bundle", "current_serial_and_batch_bundle"], + [ + "current_serial_no", + "serial_no", + "serial_and_batch_bundle", + "current_serial_and_batch_bundle", + "batch_no", + "qty", + "current_qty", + ], as_dict=True, ) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 98e369a01a8..e7f542a9774 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -697,7 +697,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) # Removed 50 Qty, Balace Qty 50 - se2 = make_stock_entry( + make_stock_entry( item_code=item_code, batch_no=batch_no, posting_time="10:00:00", @@ -730,33 +730,13 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): batch_no=batch_no, posting_time="12:00:00", source=warehouse, - qty=50, + qty=52, basic_rate=700, ) self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name})) - # Cancel the backdated Stock Entry se2, - # Since Stock Reco entry in the future the Balace Qty should remain as it's (50) - - se2.cancel() - - sle = frappe.get_all( - "Stock Ledger Entry", - filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, - fields=["qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"], - order_by="posting_time desc, creation desc", - ) - - self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0)) - - sle = frappe.get_all( - "Stock Ledger Entry", - filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)}, - fields=["actual_qty"], - ) - - self.assertEqual(flt(sle[0].actual_qty), flt(-100.0)) + self.assertRaises(frappe.ValidationError, stock_reco.cancel) def test_update_stock_reconciliation_while_reposting(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -906,27 +886,16 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): self.assertAlmostEqual(d.stock_value_difference, 500.0) # Step - 3: Create a Purchase Receipt before the first Purchase Receipt - make_purchase_receipt( - item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=200, + posting_date=add_days(nowdate(), -5), + do_not_submit=True, ) - data = frappe.get_all( - "Stock Ledger Entry", - fields=["serial_no", "actual_qty", "stock_value_difference"], - filters={"voucher_no": sr1.name, "is_cancelled": 0}, - order_by="creation", - ) - - for d in data: - if d.actual_qty < 0: - self.assertEqual(d.actual_qty, -20.0) - self.assertAlmostEqual(d.stock_value_difference, -3000.0) - else: - self.assertEqual(d.actual_qty, 5.0) - self.assertAlmostEqual(d.stock_value_difference, 500.0) - - active_serial_no = frappe.get_all("Serial No", filters={"status": "Active", "item_code": item_code}) - self.assertEqual(len(active_serial_no), 5) + self.assertRaises(frappe.ValidationError, pr.submit) def test_balance_qty_for_batch_with_backdated_stock_reco_and_future_entries(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -1464,6 +1433,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): sr = create_stock_reconciliation( item_code=item_code, + posting_date=add_days(nowdate(), -2), warehouse=warehouse, qty=10, rate=100, @@ -1483,9 +1453,9 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): self.assertTrue(len(stock_ledgers) == 1) - make_stock_entry( + se = make_stock_entry( item_code=item_code, - target=warehouse, + source=warehouse, qty=10, basic_rate=100, use_serial_batch_fields=1, @@ -1497,23 +1467,19 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): item_code=item_code, warehouse=warehouse, qty=10, - rate=100, + rate=200, use_serial_batch_fields=1, batch_no=batch_no, posting_date=add_days(nowdate(), -1), ) - stock_ledgers = frappe.get_all( + stock_ledger = frappe.get_all( "Stock Ledger Entry", - filters={"voucher_no": sr.name, "is_cancelled": 0}, - pluck="name", + filters={"voucher_no": se.name, "is_cancelled": 0}, + fields=["stock_value_difference"], ) - sr.reload() - self.assertEqual(sr.items[0].current_qty, 10) - self.assertEqual(sr.items[0].current_valuation_rate, 100) - - self.assertTrue(len(stock_ledgers) == 2) + self.assertEqual(stock_ledger[0].stock_value_difference, 2000.0 * -1) def test_serial_no_backdated_stock_reco(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -1565,7 +1531,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): self.assertTrue(status == "Active") - make_stock_entry( + se = make_stock_entry( item_code=serial_item, source=warehouse, qty=1, @@ -1591,80 +1557,6 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): self.assertFalse(status == "Active") - def test_stock_reconciliation_for_batch_with_backward(self): - # Make stock inward for 10 -> Stock Reco for 20 after two days - # Make backdated delivery note for 10 qty between stock inward and stock reco - # Check the state of the current serial and batch bundle in the stock reco - # The state should be cancelled - - from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry - - item_code = "Test Stock Reco for Batch with Backward" - - self.make_item( - item_code, {"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BCN-CB.#####"} - ) - - warehouse = "_Test Warehouse - _TC" - - se = make_stock_entry( - posting_date=add_days(nowdate(), -2), - posting_time="02:00", - item_code=item_code, - target=warehouse, - qty=10, - basic_rate=100, - ) - - batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) - - sr = create_stock_reconciliation( - item_code=item_code, - warehouse=warehouse, - qty=20, - rate=200, - use_serial_batch_fields=1, - batch_no=batch_no, - posting_date=nowdate(), - posting_time="03:00", - ) - - current_sabb = sr.items[0].current_serial_and_batch_bundle - - self.assertTrue(frappe.db.get_value("Serial and Batch Bundle", current_sabb, "docstatus") == 1) - - self.assertTrue( - frappe.db.get_value( - "Stock Ledger Entry", {"serial_and_batch_bundle": current_sabb, "is_cancelled": 0}, "name" - ) - ) - self.assertTrue(sr.items[0].current_serial_and_batch_bundle) - self.assertTrue(sr.items[0].current_qty) - self.assertTrue(sr.items[0].current_qty == 10) - - se = make_stock_entry( - posting_date=add_days(nowdate(), -1), - posting_time="02:00", - item_code=item_code, - source=warehouse, - qty=10, - basic_rate=100, - use_serial_batch_fields=1, - batch_no=batch_no, - ) - - sr.reload() - self.assertFalse(sr.items[0].current_serial_and_batch_bundle) - self.assertTrue(sr.items[0].current_qty == 0) - - self.assertFalse(frappe.db.get_value("Serial and Batch Bundle", current_sabb, "docstatus") == 1) - - self.assertFalse( - frappe.db.get_value( - "Stock Ledger Entry", {"serial_and_batch_bundle": current_sabb, "is_cancelled": 0}, "name" - ) - ) - def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 97c3cdc2b2c..16eac80c96d 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -821,7 +821,7 @@ class update_entries_after: if ( sle.voucher_type == "Stock Reconciliation" - and (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle) + and (sle.serial_and_batch_bundle) and sle.voucher_detail_no and not self.args.get("sle_id") and sle.is_cancelled == 0