fix: incorrect active serial nos due to backdated transactions
This commit is contained in:
@@ -13,7 +13,6 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item
|
|||||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
|
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
|
||||||
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
|
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
|
||||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||||
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
|
|
||||||
|
|
||||||
|
|
||||||
class TestPurchaseReceipt(FrappeTestCase):
|
class TestPurchaseReceipt(FrappeTestCase):
|
||||||
@@ -197,84 +196,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
|||||||
self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
|
self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
|
||||||
self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no}))
|
self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no}))
|
||||||
|
|
||||||
def test_duplicate_serial_nos(self):
|
|
||||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
|
||||||
|
|
||||||
item = frappe.db.exists("Item", {"item_name": "Test Serialized Item 123"})
|
|
||||||
if not item:
|
|
||||||
item = create_item("Test Serialized Item 123")
|
|
||||||
item.has_serial_no = 1
|
|
||||||
item.serial_no_series = "TSI123-.####"
|
|
||||||
item.save()
|
|
||||||
else:
|
|
||||||
item = frappe.get_doc("Item", {"item_name": "Test Serialized Item 123"})
|
|
||||||
|
|
||||||
# First make purchase receipt
|
|
||||||
pr = make_purchase_receipt(item_code=item.name, qty=2, rate=500)
|
|
||||||
pr.load_from_db()
|
|
||||||
|
|
||||||
serial_nos = frappe.db.get_value(
|
|
||||||
"Stock Ledger Entry",
|
|
||||||
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "item_code": item.name},
|
|
||||||
"serial_no",
|
|
||||||
)
|
|
||||||
|
|
||||||
serial_nos = get_serial_nos(serial_nos)
|
|
||||||
|
|
||||||
self.assertEquals(get_serial_nos(pr.items[0].serial_no), serial_nos)
|
|
||||||
|
|
||||||
# Then tried to receive same serial nos in difference company
|
|
||||||
pr_different_company = make_purchase_receipt(
|
|
||||||
item_code=item.name,
|
|
||||||
qty=2,
|
|
||||||
rate=500,
|
|
||||||
serial_no="\n".join(serial_nos),
|
|
||||||
company="_Test Company 1",
|
|
||||||
do_not_submit=True,
|
|
||||||
warehouse="Stores - _TC1",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertRaises(SerialNoDuplicateError, pr_different_company.submit)
|
|
||||||
|
|
||||||
# Then made delivery note to remove the serial nos from stock
|
|
||||||
dn = create_delivery_note(item_code=item.name, qty=2, rate=1500, serial_no="\n".join(serial_nos))
|
|
||||||
dn.load_from_db()
|
|
||||||
self.assertEquals(get_serial_nos(dn.items[0].serial_no), serial_nos)
|
|
||||||
|
|
||||||
posting_date = add_days(today(), -3)
|
|
||||||
|
|
||||||
# Try to receive same serial nos again in the same company with backdated.
|
|
||||||
pr1 = make_purchase_receipt(
|
|
||||||
item_code=item.name,
|
|
||||||
qty=2,
|
|
||||||
rate=500,
|
|
||||||
posting_date=posting_date,
|
|
||||||
serial_no="\n".join(serial_nos),
|
|
||||||
do_not_submit=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertRaises(SerialNoExistsInFutureTransaction, pr1.submit)
|
|
||||||
|
|
||||||
# Try to receive same serial nos with different company with backdated.
|
|
||||||
pr2 = make_purchase_receipt(
|
|
||||||
item_code=item.name,
|
|
||||||
qty=2,
|
|
||||||
rate=500,
|
|
||||||
posting_date=posting_date,
|
|
||||||
serial_no="\n".join(serial_nos),
|
|
||||||
company="_Test Company 1",
|
|
||||||
do_not_submit=True,
|
|
||||||
warehouse="Stores - _TC1",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertRaises(SerialNoExistsInFutureTransaction, pr2.submit)
|
|
||||||
|
|
||||||
# Receive the same serial nos after the delivery note posting date and time
|
|
||||||
make_purchase_receipt(item_code=item.name, qty=2, rate=500, serial_no="\n".join(serial_nos))
|
|
||||||
|
|
||||||
# Raise the error for backdated deliver note entry cancel
|
|
||||||
self.assertRaises(SerialNoExistsInFutureTransaction, dn.cancel)
|
|
||||||
|
|
||||||
def test_purchase_receipt_gl_entry(self):
|
def test_purchase_receipt_gl_entry(self):
|
||||||
pr = make_purchase_receipt(
|
pr = make_purchase_receipt(
|
||||||
company="_Test Company with perpetual inventory",
|
company="_Test Company with perpetual inventory",
|
||||||
|
|||||||
@@ -634,12 +634,26 @@ class StockReconciliation(StockController):
|
|||||||
if voucher_detail_no != row.name:
|
if voucher_detail_no != row.name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if row.serial_no:
|
||||||
|
item_dict = get_stock_balance_for(
|
||||||
|
row.item_code,
|
||||||
|
row.warehouse,
|
||||||
|
self.posting_date,
|
||||||
|
self.posting_time,
|
||||||
|
voucher_no=self.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_qty = item_dict.get("qty")
|
||||||
|
row.current_serial_no = item_dict.get("serial_nos")
|
||||||
|
row.current_valuation_rate = item_dict.get("rate")
|
||||||
|
else:
|
||||||
current_qty = get_batch_qty_for_stock_reco(
|
current_qty = get_batch_qty_for_stock_reco(
|
||||||
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name
|
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name
|
||||||
)
|
)
|
||||||
|
|
||||||
precesion = row.precision("current_qty")
|
precesion = row.precision("current_qty")
|
||||||
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
|
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
|
||||||
|
if not row.serial_no:
|
||||||
val_rate = get_valuation_rate(
|
val_rate = get_valuation_rate(
|
||||||
row.item_code,
|
row.item_code,
|
||||||
row.warehouse,
|
row.warehouse,
|
||||||
@@ -650,14 +664,18 @@ class StockReconciliation(StockController):
|
|||||||
)
|
)
|
||||||
|
|
||||||
row.current_valuation_rate = val_rate
|
row.current_valuation_rate = val_rate
|
||||||
|
|
||||||
row.current_qty = current_qty
|
row.current_qty = current_qty
|
||||||
row.db_set(
|
values_to_update = {
|
||||||
{
|
|
||||||
"current_qty": row.current_qty,
|
"current_qty": row.current_qty,
|
||||||
"current_valuation_rate": row.current_valuation_rate,
|
"current_valuation_rate": row.current_valuation_rate,
|
||||||
"current_amount": flt(row.current_qty * row.current_valuation_rate),
|
"current_amount": flt(row.current_qty * row.current_valuation_rate),
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if row.current_serial_no:
|
||||||
|
values_to_update["current_serial_no"] = row.current_serial_no
|
||||||
|
|
||||||
|
row.db_set(values_to_update)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
add_new_sle
|
add_new_sle
|
||||||
@@ -880,6 +898,7 @@ def get_stock_balance_for(
|
|||||||
batch_no: Optional[str] = None,
|
batch_no: Optional[str] = None,
|
||||||
with_valuation_rate: bool = True,
|
with_valuation_rate: bool = True,
|
||||||
inventory_dimensions_dict=None,
|
inventory_dimensions_dict=None,
|
||||||
|
voucher_no=None,
|
||||||
):
|
):
|
||||||
frappe.has_permission("Stock Reconciliation", "write", throw=True)
|
frappe.has_permission("Stock Reconciliation", "write", throw=True)
|
||||||
|
|
||||||
@@ -910,6 +929,7 @@ def get_stock_balance_for(
|
|||||||
with_serial_no=has_serial_no,
|
with_serial_no=has_serial_no,
|
||||||
inventory_dimensions_dict=inventory_dimensions_dict,
|
inventory_dimensions_dict=inventory_dimensions_dict,
|
||||||
batch_no=batch_no,
|
batch_no=batch_no,
|
||||||
|
voucher_no=voucher_no,
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_serial_no:
|
if has_serial_no:
|
||||||
|
|||||||
@@ -1055,6 +1055,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
|||||||
self.assertEqual(sr.items[0].current_qty, se2.items[0].qty)
|
self.assertEqual(sr.items[0].current_qty, se2.items[0].qty)
|
||||||
self.assertEqual(len(sr.items[0].current_serial_no.split("\n")), sr.items[0].current_qty)
|
self.assertEqual(len(sr.items[0].current_serial_no.split("\n")), sr.items[0].current_qty)
|
||||||
|
|
||||||
|
def test_backdated_purchase_receipt_with_stock_reco(self):
|
||||||
|
item_code = self.make_item(
|
||||||
|
properties={
|
||||||
|
"is_stock_item": 1,
|
||||||
|
"has_serial_no": 1,
|
||||||
|
"serial_no_series": "TEST-SERIAL-.###",
|
||||||
|
}
|
||||||
|
).name
|
||||||
|
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
# Step - 1: Create a Backdated Purchase Receipt
|
||||||
|
|
||||||
|
pr1 = make_purchase_receipt(
|
||||||
|
item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3)
|
||||||
|
)
|
||||||
|
pr1.reload()
|
||||||
|
|
||||||
|
serial_nos = sorted(get_serial_nos(pr1.items[0].serial_no))[:5]
|
||||||
|
|
||||||
|
# Step - 2: Create a Stock Reconciliation
|
||||||
|
sr1 = create_stock_reconciliation(
|
||||||
|
item_code=item_code,
|
||||||
|
warehouse=warehouse,
|
||||||
|
qty=5,
|
||||||
|
serial_no="\n".join(serial_nos),
|
||||||
|
)
|
||||||
|
|
||||||
|
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, -10.0)
|
||||||
|
self.assertAlmostEqual(d.stock_value_difference, -1000.0)
|
||||||
|
else:
|
||||||
|
self.assertEqual(d.actual_qty, 5.0)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# License: GNU General Public License v3. See license.txt
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional, Set, Tuple
|
from typing import Optional, Set, Tuple
|
||||||
|
|
||||||
@@ -27,10 +26,6 @@ class NegativeStockError(frappe.ValidationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SerialNoExistsInFutureTransaction(frappe.ValidationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
|
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||||
"""Create SL entries from SL entry dicts
|
"""Create SL entries from SL entry dicts
|
||||||
|
|
||||||
@@ -54,9 +49,6 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
|||||||
future_sle_exists(args, sl_entries)
|
future_sle_exists(args, sl_entries)
|
||||||
|
|
||||||
for sle in sl_entries:
|
for sle in sl_entries:
|
||||||
if sle.serial_no and not via_landed_cost_voucher:
|
|
||||||
validate_serial_no(sle)
|
|
||||||
|
|
||||||
if cancel:
|
if cancel:
|
||||||
sle["actual_qty"] = -flt(sle.get("actual_qty"))
|
sle["actual_qty"] = -flt(sle.get("actual_qty"))
|
||||||
|
|
||||||
@@ -133,35 +125,6 @@ def get_args_for_future_sle(row):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_serial_no(sle):
|
|
||||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
|
||||||
|
|
||||||
for sn in get_serial_nos(sle.serial_no):
|
|
||||||
args = copy.deepcopy(sle)
|
|
||||||
args.serial_no = sn
|
|
||||||
args.warehouse = ""
|
|
||||||
|
|
||||||
vouchers = []
|
|
||||||
for row in get_stock_ledger_entries(args, ">"):
|
|
||||||
voucher_type = frappe.bold(row.voucher_type)
|
|
||||||
voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no))
|
|
||||||
vouchers.append(f"{voucher_type} {voucher_no}")
|
|
||||||
|
|
||||||
if vouchers:
|
|
||||||
serial_no = frappe.bold(sn)
|
|
||||||
msg = (
|
|
||||||
f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first.
|
|
||||||
The list of the transactions are as below."""
|
|
||||||
+ "<br><br><ul><li>"
|
|
||||||
)
|
|
||||||
|
|
||||||
msg += "</li><li>".join(vouchers)
|
|
||||||
msg += "</li></ul>"
|
|
||||||
|
|
||||||
title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel"
|
|
||||||
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_cancellation(args):
|
def validate_cancellation(args):
|
||||||
if args[0].get("is_cancelled"):
|
if args[0].get("is_cancelled"):
|
||||||
repost_entry = frappe.db.get_value(
|
repost_entry = frappe.db.get_value(
|
||||||
@@ -573,7 +536,12 @@ class update_entries_after(object):
|
|||||||
if not self.args.get("sle_id"):
|
if not self.args.get("sle_id"):
|
||||||
self.get_dynamic_incoming_outgoing_rate(sle)
|
self.get_dynamic_incoming_outgoing_rate(sle)
|
||||||
|
|
||||||
if sle.voucher_type == "Stock Reconciliation" and sle.batch_no and sle.voucher_detail_no:
|
if (
|
||||||
|
sle.voucher_type == "Stock Reconciliation"
|
||||||
|
and not self.args.get("sle_id")
|
||||||
|
and sle.voucher_detail_no
|
||||||
|
and (sle.batch_no or sle.serial_no)
|
||||||
|
):
|
||||||
self.reset_actual_qty_for_stock_reco(sle)
|
self.reset_actual_qty_for_stock_reco(sle)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -651,11 +619,52 @@ class update_entries_after(object):
|
|||||||
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0)
|
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0)
|
||||||
|
|
||||||
if sle.actual_qty < 0:
|
if sle.actual_qty < 0:
|
||||||
sle.actual_qty = (
|
stock_reco_details = frappe.db.get_value(
|
||||||
flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"))
|
"Stock Reconciliation Item",
|
||||||
* -1
|
sle.voucher_detail_no,
|
||||||
|
["current_qty", "current_serial_no as sn_no"],
|
||||||
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sle.actual_qty = flt(stock_reco_details.current_qty) * -1
|
||||||
|
|
||||||
|
if stock_reco_details.sn_no:
|
||||||
|
sle.serial_no = stock_reco_details.sn_no
|
||||||
|
sle.qty_after_transaction = 0.0
|
||||||
|
|
||||||
|
if sle.serial_no:
|
||||||
|
self.update_serial_no_status(sle)
|
||||||
|
|
||||||
|
def update_serial_no_status(self, sle):
|
||||||
|
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||||
|
|
||||||
|
serial_nos = get_serial_nos(sle.serial_no)
|
||||||
|
warehouse = None
|
||||||
|
status = "Delivered"
|
||||||
|
if sle.actual_qty > 0:
|
||||||
|
warehouse = sle.warehouse
|
||||||
|
status = "Active"
|
||||||
|
|
||||||
|
sn_table = frappe.qb.DocType("Serial No")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.update(sn_table)
|
||||||
|
.set(sn_table.warehouse, warehouse)
|
||||||
|
.set(sn_table.status, status)
|
||||||
|
.where(sn_table.name.isin(serial_nos))
|
||||||
|
)
|
||||||
|
|
||||||
|
if sle.actual_qty > 0:
|
||||||
|
query = query.set(sn_table.purchase_document_type, sle.voucher_type)
|
||||||
|
query = query.set(sn_table.purchase_document_no, sle.voucher_no)
|
||||||
|
query = query.set(sn_table.delivery_document_type, None)
|
||||||
|
query = query.set(sn_table.delivery_document_no, None)
|
||||||
|
else:
|
||||||
|
query = query.set(sn_table.delivery_document_type, sle.voucher_type)
|
||||||
|
query = query.set(sn_table.delivery_document_no, sle.voucher_no)
|
||||||
|
|
||||||
|
query.run()
|
||||||
|
|
||||||
def validate_negative_stock(self, sle):
|
def validate_negative_stock(self, sle):
|
||||||
"""
|
"""
|
||||||
validate negative stock for entries current datetime onwards
|
validate negative stock for entries current datetime onwards
|
||||||
@@ -1282,6 +1291,9 @@ def get_stock_ledger_entries(
|
|||||||
if operator in (">", "<=") and previous_sle.get("name"):
|
if operator in (">", "<=") and previous_sle.get("name"):
|
||||||
conditions += " and name!=%(name)s"
|
conditions += " and name!=%(name)s"
|
||||||
|
|
||||||
|
if operator in (">", "<=") and previous_sle.get("voucher_no"):
|
||||||
|
conditions += " and voucher_no!=%(voucher_no)s"
|
||||||
|
|
||||||
if extra_cond:
|
if extra_cond:
|
||||||
conditions += f"{extra_cond}"
|
conditions += f"{extra_cond}"
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ def get_stock_balance(
|
|||||||
with_serial_no=False,
|
with_serial_no=False,
|
||||||
inventory_dimensions_dict=None,
|
inventory_dimensions_dict=None,
|
||||||
batch_no=None,
|
batch_no=None,
|
||||||
|
voucher_no=None,
|
||||||
):
|
):
|
||||||
"""Returns stock balance quantity at given warehouse on given posting date or current date.
|
"""Returns stock balance quantity at given warehouse on given posting date or current date.
|
||||||
|
|
||||||
@@ -115,6 +116,9 @@ def get_stock_balance(
|
|||||||
"posting_time": posting_time,
|
"posting_time": posting_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if voucher_no:
|
||||||
|
args["voucher_no"] = voucher_no
|
||||||
|
|
||||||
extra_cond = ""
|
extra_cond = ""
|
||||||
if inventory_dimensions_dict:
|
if inventory_dimensions_dict:
|
||||||
for field, value in inventory_dimensions_dict.items():
|
for field, value in inventory_dimensions_dict.items():
|
||||||
|
|||||||
Reference in New Issue
Block a user