Merge pull request #49549 from rohitwaghchaure/fixed-not-allow-backdated-entries

fix: do not allow backdated entries if stock reco exists in future for serial or batch
This commit is contained in:
rohitwaghchaure
2025-09-15 15:40:42 +05:30
committed by GitHub
4 changed files with 122 additions and 163 deletions

View File

@@ -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",

View File

@@ -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 += "<br><br><ul>"
for d in future_entries:
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
if self.has_serial_no:
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
else:
msg += f"<li>{d.batch_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
msg += "</li></ul>"
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,
)

View File

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

View File

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