diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 404758ce94f..b444e864e3d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -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.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse -from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction 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_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): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index dd39f103cd7..9a46ae71ad2 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -634,30 +634,48 @@ class StockReconciliation(StockController): if voucher_detail_no != row.name: continue - current_qty = get_batch_qty_for_stock_reco( - row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name - ) + 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( + row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name + ) precesion = row.precision("current_qty") if flt(current_qty, precesion) != flt(row.current_qty, precesion): - val_rate = get_valuation_rate( - row.item_code, - row.warehouse, - self.doctype, - self.name, - company=self.company, - batch_no=row.batch_no, - ) + if not row.serial_no: + val_rate = get_valuation_rate( + row.item_code, + row.warehouse, + self.doctype, + self.name, + company=self.company, + batch_no=row.batch_no, + ) + + row.current_valuation_rate = val_rate - row.current_valuation_rate = val_rate row.current_qty = current_qty - row.db_set( - { - "current_qty": row.current_qty, - "current_valuation_rate": row.current_valuation_rate, - "current_amount": flt(row.current_qty * row.current_valuation_rate), - } - ) + values_to_update = { + "current_qty": row.current_qty, + "current_valuation_rate": 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 ( add_new_sle @@ -880,6 +898,7 @@ def get_stock_balance_for( batch_no: Optional[str] = None, with_valuation_rate: bool = True, inventory_dimensions_dict=None, + voucher_no=None, ): frappe.has_permission("Stock Reconciliation", "write", throw=True) @@ -910,6 +929,7 @@ def get_stock_balance_for( with_serial_no=has_serial_no, inventory_dimensions_dict=inventory_dimensions_dict, batch_no=batch_no, + voucher_no=voucher_no, ) if has_serial_no: diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 05c60175f51..19c04afe909 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1055,6 +1055,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): 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) + 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): 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 df1f544d7b1..ef1b0cda4ff 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1,7 +1,6 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -import copy import json from typing import Optional, Set, Tuple @@ -27,10 +26,6 @@ class NegativeStockError(frappe.ValidationError): pass -class SerialNoExistsInFutureTransaction(frappe.ValidationError): - pass - - def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): """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) for sle in sl_entries: - if sle.serial_no and not via_landed_cost_voucher: - validate_serial_no(sle) - if cancel: 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.""" - + "

" - - title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel" - frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction) - - def validate_cancellation(args): if args[0].get("is_cancelled"): repost_entry = frappe.db.get_value( @@ -573,7 +536,12 @@ class update_entries_after(object): if not self.args.get("sle_id"): 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) if ( @@ -651,11 +619,52 @@ class update_entries_after(object): doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0) if sle.actual_qty < 0: - sle.actual_qty = ( - flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty")) - * -1 + stock_reco_details = frappe.db.get_value( + "Stock Reconciliation Item", + 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): """ 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"): conditions += " and name!=%(name)s" + if operator in (">", "<=") and previous_sle.get("voucher_no"): + conditions += " and voucher_no!=%(voucher_no)s" + if extra_cond: conditions += f"{extra_cond}" diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index e019c572daf..2b57a1be8fa 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -96,6 +96,7 @@ def get_stock_balance( with_serial_no=False, inventory_dimensions_dict=None, batch_no=None, + voucher_no=None, ): """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, } + if voucher_no: + args["voucher_no"] = voucher_no + extra_cond = "" if inventory_dimensions_dict: for field, value in inventory_dimensions_dict.items():