diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f53769155bd..a916478d4b3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -384,3 +384,4 @@ erpnext.patches.v15_0.update_task_assignee_email_field_in_asset_maintenance_log erpnext.patches.v15_0.update_sub_voucher_type_in_gl_entries erpnext.patches.v14_0.update_stock_uom_in_work_order_item erpnext.patches.v15_0.set_is_exchange_gain_loss_in_payment_entry_deductions +erpnext.patches.v15_0.enable_allow_existing_serial_no diff --git a/erpnext/patches/v15_0/enable_allow_existing_serial_no.py b/erpnext/patches/v15_0/enable_allow_existing_serial_no.py new file mode 100644 index 00000000000..e13adc2b187 --- /dev/null +++ b/erpnext/patches/v15_0/enable_allow_existing_serial_no.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + if frappe.get_all("Company", filters={"country": "India"}, limit=1): + frappe.db.set_single_value("Stock Settings", "allow_existing_serial_no", 1) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 4b8d5101f43..86d1a6948de 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -3948,6 +3948,42 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(return_pr.per_billed, 100) self.assertEqual(return_pr.status, "Completed") + def test_do_not_allow_to_inward_same_serial_no_multiple_times(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + frappe.db.set_single_value("Stock Settings", "allow_existing_serial_no", 0) + + item_code = make_item( + "Test Do Not Allow INWD Item 123", {"has_serial_no": 1, "serial_no_series": "SN-TDAISN-.#####"} + ).name + + pr = make_purchase_receipt(item_code=item_code, qty=1, rate=100, use_serial_batch_fields=1) + serial_no = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0] + + status = frappe.db.get_value("Serial No", serial_no, "status") + self.assertTrue(status == "Active") + + make_stock_entry( + item_code=item_code, + source=pr.items[0].warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + ) + + status = frappe.db.get_value("Serial No", serial_no, "status") + self.assertFalse(status == "Active") + + pr = make_purchase_receipt( + item_code=item_code, qty=1, rate=100, use_serial_batch_fields=1, do_not_submit=1 + ) + pr.items[0].serial_no = serial_no + pr.save() + + self.assertRaises(frappe.exceptions.ValidationError, pr.submit) + + frappe.db.set_single_value("Stock Settings", "allow_existing_serial_no", 1) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4bd8f5e8465..a1f3135b70b 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -89,6 +89,10 @@ class SerialandBatchBundle(Document): self.validate_serial_and_batch_no() self.validate_duplicate_serial_and_batch_no() self.validate_voucher_no() + + if self.docstatus == 0: + self.allow_existing_serial_nos() + if self.type_of_transaction == "Maintenance": return @@ -102,6 +106,42 @@ class SerialandBatchBundle(Document): self.set_incoming_rate() self.calculate_qty_and_amount() + def allow_existing_serial_nos(self): + if self.type_of_transaction == "Outward" or not self.has_serial_no: + return + + if frappe.db.get_single_value("Stock Settings", "allow_existing_serial_no"): + return + + if self.voucher_type not in ["Purchase Receipt", "Purchase Invoice", "Stock Entry"]: + return + + if self.voucher_type == "Stock Entry" and frappe.get_cached_value( + "Stock Entry", self.voucher_no, "purpose" + ) in ["Material Transfer", "Send to Subcontractor", "Material Transfer for Manufacture"]: + return + + serial_nos = [d.serial_no for d in self.entries if d.serial_no] + + data = frappe.get_all( + "Serial and Batch Entry", + filters={"serial_no": ("in", serial_nos), "docstatus": 1, "qty": ("<", 0)}, + fields=["serial_no", "parent"], + ) + + note = "

Note:
" + for row in data: + frappe.throw( + _( + "You can't process the serial number {0} as it has already been used in the SABB {1}. {2} if you want to inward same serial number multiple times then enabled 'Allow existing Serial No to be Manufactured/Received again' in the {3}" + ).format( + row.serial_no, + get_link_to_form("Serial and Batch Bundle", row.parent), + note, + get_link_to_form("Stock Settings", "Stock Settings"), + ) + ) + def reset_serial_batch_bundle(self): if self.is_new() and self.amended_from: for field in ["is_cancelled", "is_rejected"]: @@ -136,7 +176,12 @@ class SerialandBatchBundle(Document): return serial_nos = [d.serial_no for d in self.entries if d.serial_no] - kwargs = {"item_code": self.item_code, "warehouse": self.warehouse} + kwargs = { + "item_code": self.item_code, + "warehouse": self.warehouse, + "check_serial_nos": True, + "serial_nos": serial_nos, + } if self.voucher_type == "POS Invoice": kwargs["ignore_voucher_nos"] = [self.voucher_no] @@ -177,6 +222,7 @@ class SerialandBatchBundle(Document): "posting_date": self.posting_date, "posting_time": self.posting_time, "serial_nos": serial_nos, + "check_serial_nos": True, } ) @@ -1683,7 +1729,7 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): serial_nos = set() data = get_stock_ledgers_for_serial_nos(kwargs) - bundle_wise_serial_nos = get_bundle_wise_serial_nos(data) + bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, kwargs) for d in data: if d.serial_and_batch_bundle: if sns := bundle_wise_serial_nos.get(d.serial_and_batch_bundle): @@ -1707,16 +1753,21 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): return serial_nos -def get_bundle_wise_serial_nos(data): +def get_bundle_wise_serial_nos(data, kwargs): bundle_wise_serial_nos = defaultdict(list) bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle] if not bundles: return bundle_wise_serial_nos + filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")} + + if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): + filters["serial_no"] = ("in", kwargs.get("serial_nos")) + bundle_data = frappe.get_all( "Serial and Batch Entry", fields=["serial_no", "parent"], - filters={"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")}, + filters=filters, ) for d in bundle_data: @@ -2277,6 +2328,8 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: def get_stock_ledgers_for_serial_nos(kwargs): + from erpnext.stock.utils import get_combine_datetime + stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -2287,15 +2340,16 @@ def get_stock_ledgers_for_serial_nos(kwargs): stock_ledger_entry.serial_and_batch_bundle, ) .where(stock_ledger_entry.is_cancelled == 0) + .orderby(stock_ledger_entry.posting_datetime) ) if kwargs.get("posting_date"): if kwargs.get("posting_time") is None: kwargs.posting_time = nowtime() - timestamp_condition = CombineDatetime( - stock_ledger_entry.posting_date, stock_ledger_entry.posting_time - ) <= CombineDatetime(kwargs.posting_date, kwargs.posting_time) + timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( + kwargs.posting_date, kwargs.posting_time + ) query = query.where(timestamp_condition) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 069e7da41cb..e542a1582e3 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -49,12 +49,13 @@ "do_not_use_batchwise_valuation", "auto_create_serial_and_batch_bundle_for_outward", "pick_serial_and_batch_based_on", + "naming_series_prefix", "column_break_mhzc", "disable_serial_no_and_batch_selector", "use_naming_series", - "naming_series_prefix", "use_serial_batch_fields", "do_not_update_serial_batch_on_creation_of_auto_bundle", + "allow_existing_serial_no", "stock_planning_tab", "auto_material_request", "auto_indent", @@ -460,6 +461,12 @@ "fieldname": "over_picking_allowance", "fieldtype": "Percent", "label": "Over Picking Allowance" + }, + { + "default": "1", + "fieldname": "allow_existing_serial_no", + "fieldtype": "Check", + "label": "Allow existing Serial No to be Manufactured/Received again" } ], "icon": "icon-cog", @@ -467,7 +474,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-07-29 14:55:19.093508", + "modified": "2024-12-09 17:52:36.030456", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 229ff944750..b7a317cd66a 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -25,6 +25,7 @@ class StockSettings(Document): action_if_quality_inspection_is_not_submitted: DF.Literal["Stop", "Warn"] action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"] + allow_existing_serial_no: DF.Check allow_from_dn: DF.Check allow_from_pr: DF.Check allow_internal_transfer_at_arms_length_price: DF.Check