diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index fce149e0e84..916d9865662 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -921,6 +921,10 @@ class StockController(AccountsController): "Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1} ) + frappe.db.set_value( + "Serial and Batch Entry", {"parent": row.serial_and_batch_bundle}, {"is_cancelled": 1} + ) + if update_values: row.db_set(update_values) @@ -929,6 +933,12 @@ class StockController(AccountsController): "Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, {"is_cancelled": 1} ) + frappe.db.set_value( + "Serial and Batch Entry", + {"parent": row.rejected_serial_and_batch_bundle}, + {"is_cancelled": 1}, + ) + row.db_set("rejected_serial_and_batch_bundle", None) if row.get("current_serial_and_batch_bundle"): @@ -2310,6 +2320,7 @@ def make_bundle_for_material_transfer(**kwargs): row.voucher_no = bundle_doc.voucher_no row.voucher_detail_no = bundle_doc.voucher_detail_no row.type_of_transaction = bundle_doc.type_of_transaction + row.item_code = bundle_doc.item_code bundle_doc.set_incoming_rate() bundle_doc.calculate_qty_and_amount() diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ce466bc94cd..8b7653f5967 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -443,7 +443,7 @@ erpnext.patches.v16_0.rename_subcontracted_quantity erpnext.patches.v16_0.add_new_stock_entry_types erpnext.patches.v15_0.set_asset_status_if_not_already_set erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing -erpnext.patches.v16_0.update_serial_batch_entries +erpnext.patches.v16_0.update_serial_batch_entries #11-01-2026 10:00:00 erpnext.patches.v16_0.set_company_wise_warehouses erpnext.patches.v16_0.set_valuation_method_on_companies erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table diff --git a/erpnext/patches/v16_0/update_serial_batch_entries.py b/erpnext/patches/v16_0/update_serial_batch_entries.py index 26a817dc7bf..a2391edd57f 100644 --- a/erpnext/patches/v16_0/update_serial_batch_entries.py +++ b/erpnext/patches/v16_0/update_serial_batch_entries.py @@ -11,7 +11,9 @@ def execute(): SABE.voucher_type = SABB.voucher_type, SABE.voucher_no = SABB.voucher_no, SABE.voucher_detail_no = SABB.voucher_detail_no, - SABE.type_of_transaction = SABB.type_of_transaction + SABE.type_of_transaction = SABB.type_of_transaction, + SABE.is_cancelled = SABB.is_cancelled, + SABE.item_code = SABB.item_code WHERE SABE.parent = SABB.name """ ) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 9b02ebe2f7a..d85a76f9f75 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2806,6 +2806,64 @@ class TestDeliveryNote(IntegrationTestCase): frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision) + def test_different_rate_for_same_serial_nos(self): + item_code = make_item( + "Test Different Rate Serial No Item", + properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "DRSN-.#####"}, + ).name + + se = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100) + serial_nos = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle) + + dn = create_delivery_note( + item_code=item_code, + qty=1, + rate=300, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + + dn.reload() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 100) + + make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=1, + basic_rate=200, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + dn1 = create_delivery_note( + item_code=item_code, + qty=1, + rate=300, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + + dn1.reload() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 200) + + doc = frappe.new_doc("Repost Item Valuation") + doc.voucher_type = "Stock Entry" + doc.voucher_no = se.name + doc.submit() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 100) + + sabb = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 200) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") 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 c42a1e57c32..8b50e1666ef 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 @@ -139,6 +139,7 @@ class SerialandBatchBundle(Document): self.set_incoming_rate() self.calculate_qty_and_amount() + self.set_child_details() def validate_serial_no_status(self): serial_nos = [d.serial_no for d in self.entries if d.serial_no] @@ -1342,8 +1343,18 @@ class SerialandBatchBundle(Document): self.set_source_document_no() def on_submit(self): + self.validate_docstatus() self.validate_serial_nos_inventory() + def validate_docstatus(self): + for row in self.entries: + if row.docstatus != 1: + frappe.throw( + _("At Row {0}: In Serial and Batch Bundle {1} must have docstatus as 1 and not 0").format( + bold(row.idx), bold(self.name) + ) + ) + def set_child_details(self): for row in self.entries: for field in [ @@ -1353,6 +1364,7 @@ class SerialandBatchBundle(Document): "voucher_no", "voucher_detail_no", "type_of_transaction", + "item_code", ]: if not row.get(field) or row.get(field) != self.get(field): row.set(field, self.get(field)) diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json index 69aaf261945..b5d0200c1c2 100644 --- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json +++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json @@ -7,6 +7,7 @@ "field_order": [ "serial_no", "batch_no", + "item_code", "column_break_2", "qty", "warehouse", @@ -22,6 +23,7 @@ "reference_for_reservation", "voucher_type", "voucher_no", + "is_cancelled", "column_break_eykr", "posting_datetime", "type_of_transaction", @@ -146,24 +148,28 @@ "fieldname": "posting_datetime", "fieldtype": "Datetime", "label": "Posting Datetime", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_type", "fieldtype": "Data", "label": "Voucher Type", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_no", "fieldtype": "Data", "label": "Voucher No", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_detail_no", "fieldtype": "Data", "label": "Voucher Detail No", + "no_copy": 1, "read_only": 1, "search_index": 1 }, @@ -171,18 +177,35 @@ "fieldname": "type_of_transaction", "fieldtype": "Data", "label": "Type of Transaction", + "no_copy": 1, "read_only": 1, "search_index": 1 }, { "fieldname": "column_break_eykr", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_cancelled", + "fieldtype": "Check", + "label": "Is Cancelled", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "no_copy": 1, + "options": "Item", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-11-09 23:28:35.191959", + "modified": "2026-01-11 11:05:10.789054", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Entry", diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py index 1f084e60c9c..adeb6a388da 100644 --- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py +++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py @@ -17,7 +17,9 @@ class SerialandBatchEntry(Document): batch_no: DF.Link | None delivered_qty: DF.Float incoming_rate: DF.Float + is_cancelled: DF.Check is_outward: DF.Check + item_code: DF.Link | None outgoing_rate: DF.Float parent: DF.Data parentfield: DF.Data diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index e52ce3567cb..13d7b100855 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -324,6 +324,12 @@ class SerialBatchBundle: {"is_cancelled": 1}, ) + frappe.db.set_value( + "Serial and Batch Entry", + {"voucher_no": self.sle.voucher_no, "voucher_type": self.sle.voucher_type}, + {"is_cancelled": 1}, + ) + if self.sle.serial_and_batch_bundle: frappe.get_cached_doc( "Serial and Batch Bundle", self.sle.serial_and_batch_bundle @@ -642,26 +648,23 @@ class SerialNoValuation(DeprecatedSerialNoValuation): self.calculate_stock_value_from_deprecarated_ledgers() def get_serial_no_wise_incoming_rate(self, serial_nos): - bundle = frappe.qb.DocType("Serial and Batch Bundle") bundle_child = frappe.qb.DocType("Serial and Batch Entry") def get_latest_based_on_posting_datetime(): # Get latest inward record based on posting datetime for each serial no latest_posting = ( - frappe.qb.from_(bundle) - .inner_join(bundle_child) - .on(bundle.name == bundle_child.parent) + frappe.qb.from_(bundle_child) .select( bundle_child.serial_no, - Max(bundle.posting_datetime).as_("max_posting_dt"), + Max(bundle_child.posting_datetime).as_("max_posting_dt"), ) .where( - (bundle.is_cancelled == 0) - & (bundle.docstatus == 1) - & (bundle.type_of_transaction == "Inward") + (bundle_child.is_cancelled == 0) + & (bundle_child.docstatus == 1) + & (bundle_child.type_of_transaction == "Inward") & (bundle_child.qty > 0) - & (bundle.item_code == self.sle.item_code) + & (bundle_child.item_code == self.sle.item_code) & (bundle_child.warehouse == self.sle.warehouse) & (bundle_child.serial_no.isin(serial_nos)) ) @@ -670,10 +673,10 @@ class SerialNoValuation(DeprecatedSerialNoValuation): # Important to exclude the current voucher to calculate correct the stock value difference if self.sle.voucher_no: - latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no) + latest_posting = latest_posting.where(bundle_child.voucher_no != self.sle.voucher_no) if self.sle.posting_datetime: - timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime + timestamp_condition = bundle_child.posting_datetime <= self.sle.posting_datetime latest_posting = latest_posting.where(timestamp_condition) @@ -684,24 +687,22 @@ class SerialNoValuation(DeprecatedSerialNoValuation): def get_latest_based_on_creation(latest_posting): # Get latest inward record based on creation for each serial no latest_creation = ( - frappe.qb.from_(bundle) - .join(bundle_child) - .on(bundle.name == bundle_child.parent) + frappe.qb.from_(bundle_child) .join(latest_posting) .on( (latest_posting.serial_no == bundle_child.serial_no) - & (latest_posting.max_posting_dt == bundle.posting_datetime) + & (latest_posting.max_posting_dt == bundle_child.posting_datetime) ) .select( bundle_child.serial_no, - Max(bundle.creation).as_("max_creation"), + Max(bundle_child.creation).as_("max_creation"), ) .where( - (bundle.is_cancelled == 0) - & (bundle.docstatus == 1) - & (bundle.type_of_transaction == "Inward") + (bundle_child.is_cancelled == 0) + & (bundle_child.docstatus == 1) + & (bundle_child.type_of_transaction == "Inward") & (bundle_child.qty > 0) - & (bundle.item_code == self.sle.item_code) + & (bundle_child.item_code == self.sle.item_code) & (bundle_child.warehouse == self.sle.warehouse) ) .groupby(bundle_child.serial_no) @@ -713,13 +714,11 @@ class SerialNoValuation(DeprecatedSerialNoValuation): latest_creation = get_latest_based_on_creation(latest_posting) query = ( - frappe.qb.from_(bundle) - .join(bundle_child) - .on(bundle.name == bundle_child.parent) + frappe.qb.from_(bundle_child) .join(latest_creation) .on( (latest_creation.serial_no == bundle_child.serial_no) - & (latest_creation.max_creation == bundle.creation) + & (latest_creation.max_creation == bundle_child.creation) ) .select( bundle_child.serial_no, @@ -841,7 +840,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation): Sum(child.qty).as_("total_qty"), ) .where( - (child.warehouse == self.sle.warehouse) + (child.item_code == self.sle.item_code) + & (child.warehouse == self.sle.warehouse) & (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.docstatus == 1) & (child.type_of_transaction.isin(["Inward", "Outward"])) @@ -887,7 +887,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation): Sum(child.qty).as_("qty"), ) .where( - (child.warehouse == self.sle.warehouse) + (child.item_code == self.sle.item_code) + & (child.warehouse == self.sle.warehouse) & (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.docstatus == 1) & (child.type_of_transaction.isin(["Inward", "Outward"]))