fix: do not allow backdated entries if stock reco exists in future for serial or batch

(cherry picked from commit 335dcc976c)

# Conflicts:
#	erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json
This commit is contained in:
Rohit Waghchaure
2025-09-15 10:59:24 +05:30
committed by Mergify
parent e92ec66890
commit 44869f02b4
4 changed files with 125 additions and 162 deletions

View File

@@ -108,7 +108,8 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Voucher Type", "label": "Voucher Type",
"options": "DocType", "options": "DocType",
"reqd": 1 "reqd": 1,
"search_index": 1
}, },
{ {
"fieldname": "voucher_no", "fieldname": "voucher_no",
@@ -195,8 +196,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "Type of Transaction", "label": "Type of Transaction",
"options": "\nInward\nOutward\nMaintenance\nAsset Repair", "options": "\nInward\nOutward\nMaintenance\nAsset Repair",
"reqd": 1, "reqd": 1
"search_index": 1
}, },
{ {
"default": "0", "default": "0",
@@ -256,7 +256,11 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
<<<<<<< HEAD
"modified": "2025-02-17 18:22:36.056205", "modified": "2025-02-17 18:22:36.056205",
=======
"modified": "2025-09-15 14:37:26.441742",
>>>>>>> 335dcc976c (fix: do not allow backdated entries if stock reco exists in future for serial or batch)
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial and Batch Bundle", "name": "Serial and Batch Bundle",

View File

@@ -118,8 +118,8 @@ class SerialandBatchBundle(Document):
self.allow_existing_serial_nos() self.allow_existing_serial_nos()
if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test: if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test:
self.validate_serial_nos_duplicate() self.validate_serial_nos_duplicate()
self.check_future_entries_exists()
self.check_future_entries_exists()
self.set_is_outward() self.set_is_outward()
self.calculate_total_qty() self.calculate_total_qty()
self.set_warehouse() self.set_warehouse()
@@ -228,7 +228,7 @@ class SerialandBatchBundle(Document):
return return
if self.voucher_type == "Stock Reconciliation": if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate() serial_nos, batches = self.get_serial_nos_for_validate()
else: else:
serial_nos = [d.serial_no for d in self.entries if d.serial_no] serial_nos = [d.serial_no for d in self.entries if d.serial_no]
@@ -716,15 +716,22 @@ class SerialandBatchBundle(Document):
if self.flags and self.flags.via_landed_cost_voucher: if self.flags and self.flags.via_landed_cost_voucher:
return return
if not self.has_serial_no: serial_nos = []
return batches = []
if self.voucher_type == "Stock Reconciliation": 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: 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] 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 return
parent = frappe.qb.DocType("Serial and Batch Bundle") parent = frappe.qb.DocType("Serial and Batch Bundle")
@@ -740,65 +747,117 @@ class SerialandBatchBundle(Document):
.on(parent.name == child.parent) .on(parent.name == child.parent)
.select( .select(
child.serial_no, child.serial_no,
child.batch_no,
parent.voucher_type, parent.voucher_type,
parent.voucher_no, parent.voucher_no,
) )
.where( .where(
(child.serial_no.isin(serial_nos)) (child.parent != self.name)
& (child.parent != self.name)
& (parent.item_code == self.item_code) & (parent.item_code == self.item_code)
& (parent.docstatus == 1) & (parent.docstatus == 1)
& (parent.is_cancelled == 0) & (parent.is_cancelled == 0)
& (parent.type_of_transaction.isin(["Inward", "Outward"])) & (parent.type_of_transaction.isin(["Inward", "Outward"]))
) )
.where(timestamp_condition) .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: if future_entries:
msg = """The serial nos has been used in the future if self.has_serial_no:
transactions so you need to cancel them first. title = "Serial No Exists In Future Transaction(s)"
The list of serial nos and their respective else:
transactions are as below.""" 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>" msg += "<br><br><ul>"
for d in future_entries: 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>" msg += "</li></ul>"
title = "Serial No Exists In Future Transaction(s)"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError) frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
def get_serial_nos_for_validate(self, is_cancelled=False): def get_serial_nos_for_validate(self, is_cancelled=False):
serial_nos = [d.serial_no for d in self.entries if d.serial_no] 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) batches = [d.batch_no for d in self.entries if d.batch_no]
serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos)))
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): def get_skip_serial_nos_for_stock_reconciliation(self, is_cancelled=False):
data = get_stock_reco_details(self.voucher_detail_no) data = get_stock_reco_details(self.voucher_detail_no)
if not data: if not data:
return [] return [], []
current_serial_nos = set()
serial_nos = set()
current_batches = set()
batches = set()
if data.current_serial_no: if data.current_serial_no:
current_serial_nos = set(parse_serial_nos(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([]) 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: 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: if is_cancelled:
return current_serial_nos return list(current_serial_nos), list(current_batches)
serial_nos = ( if self.has_serial_no:
set(get_serial_nos_from_bundle(data.serial_and_batch_bundle)) serial_nos = (
if data.serial_and_batch_bundle set(get_serial_nos_from_bundle(data.serial_and_batch_bundle))
else set([]) 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): def reset_qty(self, row, qty_field=None):
qty_field = self.get_qty_field(row, qty_field=qty_field) qty_field = self.get_qty_field(row, qty_field=qty_field)
@@ -2673,6 +2732,14 @@ def get_stock_reco_details(voucher_detail_no):
return frappe.db.get_value( return frappe.db.get_value(
"Stock Reconciliation Item", "Stock Reconciliation Item",
voucher_detail_no, 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, as_dict=True,
) )

View File

@@ -696,7 +696,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
# Removed 50 Qty, Balace Qty 50 # Removed 50 Qty, Balace Qty 50
se2 = make_stock_entry( make_stock_entry(
item_code=item_code, item_code=item_code,
batch_no=batch_no, batch_no=batch_no,
posting_time="10:00:00", posting_time="10:00:00",
@@ -729,33 +729,13 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
batch_no=batch_no, batch_no=batch_no,
posting_time="12:00:00", posting_time="12:00:00",
source=warehouse, source=warehouse,
qty=50, qty=52,
basic_rate=700, basic_rate=700,
) )
self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name})) self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name}))
# Cancel the backdated Stock Entry se2, self.assertRaises(frappe.ValidationError, stock_reco.cancel)
# 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))
def test_update_stock_reconciliation_while_reposting(self): def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -905,27 +885,16 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertAlmostEqual(d.stock_value_difference, 500.0) self.assertAlmostEqual(d.stock_value_difference, 500.0)
# Step - 3: Create a Purchase Receipt before the first Purchase Receipt # Step - 3: Create a Purchase Receipt before the first Purchase Receipt
make_purchase_receipt( pr = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5) item_code=item_code,
warehouse=warehouse,
qty=10,
rate=200,
posting_date=add_days(nowdate(), -5),
do_not_submit=True,
) )
data = frappe.get_all( self.assertRaises(frappe.ValidationError, pr.submit)
"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)
def test_balance_qty_for_batch_with_backdated_stock_reco_and_future_entries(self): 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 from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -1463,6 +1432,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code=item_code, item_code=item_code,
posting_date=add_days(nowdate(), -2),
warehouse=warehouse, warehouse=warehouse,
qty=10, qty=10,
rate=100, rate=100,
@@ -1482,9 +1452,9 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertTrue(len(stock_ledgers) == 1) self.assertTrue(len(stock_ledgers) == 1)
make_stock_entry( se = make_stock_entry(
item_code=item_code, item_code=item_code,
target=warehouse, source=warehouse,
qty=10, qty=10,
basic_rate=100, basic_rate=100,
use_serial_batch_fields=1, use_serial_batch_fields=1,
@@ -1496,23 +1466,19 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code, item_code=item_code,
warehouse=warehouse, warehouse=warehouse,
qty=10, qty=10,
rate=100, rate=200,
use_serial_batch_fields=1, use_serial_batch_fields=1,
batch_no=batch_no, batch_no=batch_no,
posting_date=add_days(nowdate(), -1), posting_date=add_days(nowdate(), -1),
) )
stock_ledgers = frappe.get_all( stock_ledger = frappe.get_all(
"Stock Ledger Entry", "Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0}, filters={"voucher_no": se.name, "is_cancelled": 0},
pluck="name", fields=["stock_value_difference"],
) )
sr.reload() self.assertEqual(stock_ledger[0].stock_value_difference, 2000.0 * -1)
self.assertEqual(sr.items[0].current_qty, 10)
self.assertEqual(sr.items[0].current_valuation_rate, 100)
self.assertTrue(len(stock_ledgers) == 2)
def test_serial_no_backdated_stock_reco(self): def test_serial_no_backdated_stock_reco(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -1564,7 +1530,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertTrue(status == "Active") self.assertTrue(status == "Active")
make_stock_entry( se = make_stock_entry(
item_code=serial_item, item_code=serial_item,
source=warehouse, source=warehouse,
qty=1, qty=1,
@@ -1590,80 +1556,6 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertFalse(status == "Active") 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): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -817,7 +817,7 @@ class update_entries_after:
if ( if (
sle.voucher_type == "Stock Reconciliation" 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 sle.voucher_detail_no
and not self.args.get("sle_id") and not self.args.get("sle_id")
and sle.is_cancelled == 0 and sle.is_cancelled == 0