mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-15 15:45:01 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user