Skip to content

Conversation

@elliotnor
Copy link

Closes #49520

Description

This PR fixes a bug where serial numbers that have already been delivered can be reused in manufacturing (Stock Entry with "Manufacture" purpose). This causes data loss as the original serial number information gets overwritten, and the delivered item loses its serial number assignment.

Problem

When creating a Stock Entry for manufacturing with the "Manufacture" purpose, we were not validating whether serial numbers in the incoming bundle had already been delivered.

Solution

Added validation in validate_serial_nos_duplicate() method to:

  1. Check if serial numbers in incoming/outgoing bundles have already been delivered
  2. Allow return transactions to legitimately reuse delivered serial numbers by checking:
    • The returned_against field on the bundle
    • The is_return flag on the parent voucher
  3. Raise SerialNoDuplicateError if a delivered serial number is being reused in a non-return transaction

Changes Made

Files Modified:

  • erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
    • Enhanced validate_serial_nos_duplicate() to check for delivered serial numbers
    • Added new is_return_transaction() method to properly identify return transactions

Tests Added:

  • test_delivered_serial_no_reuse_blocked() - Verifies that delivered serial numbers cannot be reused
  • test_return_transaction_allows_delivered_serial() - Verifies that return transactions can legitimately use delivered serial numbers

Screencast

Before:
screencast_before.webm

After:
screencast_after.webm

Delivered serial numbers could be reused in manufacturing, overwriting the original item data. Now blocked for non-return transactions while allowing legitimate returns.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 3, 2025

📝 Walkthrough

Walkthrough

This PR modifies the serial and batch bundle module to replace separate posting_date and posting_time fields with a unified posting_datetime field, and introduces return transaction detection to prevent duplicate serial number reuse for non-return transactions while allowing reuse in return scenarios. It expands method signatures to pass prev_sle through valuation flows, updates validation logic in validate_serial_nos_duplicate, modifies get_serial_nos_for_validate to return batch information alongside serial numbers, introduces helper functions for reserved serial management, adds stock-queue handling for FIFO valuation, and performs numerous query and ledger-fetching updates to use posting_datetime. Test cases are added to verify delivered serial number reuse blocking and return transaction handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • validate_serial_nos_duplicate logic: Critical changes to detect and block delivered serial reuse for non-return transactions while permitting reuse in return flows—careful review needed to ensure edge cases are handled
  • posting_datetime migration: Schema change propagated through numerous query filters, ledger fetches, and ordering logic across multiple data paths—verify all query builders correctly filter/order by posting_datetime
  • prev_sle propagation: New parameter added to set_incoming_rate, set_incoming_rate_for_inward_transaction, and set_valuation_rate_for_return_entry—ensure backward compatibility and correct flow through valuation paths
  • Reserved serial and batch handling: New helpers get_reserved_serial_nos_for_voucher and get_other_doc_reserved_serials introduced for managing reserved serials with vouchers—verify interaction with existing reservation logic
  • Stock queue FIFO handling: Introduction of stock-queue state management and persistence—verify correctness of queue updates and serialization
  • Return transaction detection: New is_return_transaction method and related branching logic—ensure return detection logic correctly identifies return scenarios across transaction types

Possibly related PRs

Suggested labels

stock, needs-tests, backport version-15-hotfix

Suggested reviewers

  • rohitwaghchaure
  • mihir-kandoi

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive The raw_summary indicates extensive changes to the serial_and_batch_bundle.py file beyond what is described in the PR description, including replacement of posting_date/posting_time with posting_datetime, additions of prev_sle parameter propagation, stock_queue handling, batch-aware validation, and numerous database migration updates. While these changes may be related to enabling the serial number reuse fix, their scope appears significantly broader than the stated objective of adding duplicate serial number validation. Clarify whether the posting_datetime refactoring, prev_sle propagation, stock_queue handling, and batch-aware changes are necessary for the serial reuse prevention feature or represent separate refactoring work. If they are separate changes, consider splitting them into a separate PR to maintain clear scope boundaries and easier review/rollback if needed.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'fix(stock): prevent serial number reuse in manufacturing after delivery' accurately and concisely describes the main change. It clearly identifies the bug being fixed (preventing serial number reuse), the context (manufacturing/Stock Entry), and the condition (after delivery). The title is specific and relates directly to the changeset's primary objective.
Description check ✅ Passed The PR description is comprehensive and well-related to the changeset. It clearly explains the problem, solution, affected files, and test cases. The description provides context about the bug (serial numbers from delivered items being reused), the specific validation added, and the mechanism for allowing returns while blocking other reuse cases. It includes references to the linked issue and provides before/after screencasts.
Linked Issues check ✅ Passed The PR successfully addresses the requirements from issue #49520. The code changes implement the core requirement: preventing delivered serial numbers from being reused in non-return transactions. The implementation adds validation in validate_serial_nos_duplicate() to check if serial numbers have been delivered, properly distinguishes return transactions from regular Stock Entries, and raises SerialNoDuplicateError when appropriate. Two test cases verify both the blocking behavior and the legitimate return flow.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py (1)

274-331: The is_return_transaction() method does not check Stock Entry's is_return flag, breaking manufacturing return scenarios.

The implementation prevents reusing delivered serial numbers for non-return inward transactions (lines 290-307), which is correct. However, is_return_transaction() (lines 332-360) only checks is_return flag for these voucher types: Delivery Note, Sales Invoice, Purchase Invoice, Purchase Receipt, POS Invoice, and Subcontracting Receipt.

Stock Entry is missing from this list. Since manufacturing entries can legitimately have is_return=1 (as seen in work_order.py line 2373), manufacturing return entries will fail the delivered serial number check even though they should allow reuse. Manufacturing finished goods are "Inward" transactions, so they'll hit the validation, but is_return_transaction() returns False, triggering a false error.

Add "Stock Entry" to the checked voucher types in is_return_transaction() at line 345:

if self.voucher_type in [
    "Delivery Note",
    "Sales Invoice",
    "Purchase Invoice",
    "Purchase Receipt",
    "POS Invoice",
    "Stock Entry",  # Add this line
    "Subcontracting Receipt",
]:
🧹 Nitpick comments (2)
erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py (2)

909-952: Consider simulating actual delivery flow for more comprehensive testing.

The test directly sets the serial number status to "Delivered" (line 926) without simulating a full delivery transaction. While this tests the validation logic, it doesn't verify that the serial status is correctly updated through the actual delivery flow. Consider creating a delivery note similar to test_return_transaction_allows_delivered_serial to make the test more representative of real-world usage.

Example approach:

 	def test_delivered_serial_no_reuse_blocked(self):
 		from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
 			SerialNoDuplicateError,
 		)
+		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
 
 		item_code = make_item(
 			"Test Delivered Serial Reuse",
 			properties={"has_serial_no": 1, "serial_no_series": "TEST-DSR-.####", "is_stock_item": 1},
 		).name
 
 		serial_no = "TEST-DSR-0001"
 		if not frappe.db.exists("Serial No", serial_no):
 			sn_doc = frappe.get_doc(
 				{
 					"doctype": "Serial No",
 					"serial_no": serial_no,
 					"item_code": item_code,
-					"status": "Delivered",
 				}
 			)
 			sn_doc.insert(ignore_permissions=True)
-		else:
-			frappe.db.set_value("Serial No", serial_no, "status", "Delivered")
+		
+		# First receive the serial number
+		make_stock_entry(
+			item_code=item_code,
+			target="_Test Warehouse - _TC",
+			qty=1,
+			rate=100,
+			serial_no=[serial_no],
+		)
+		
+		# Deliver it to make status "Delivered"
+		create_delivery_note(
+			item_code=item_code, qty=1, rate=100, warehouse="_Test Warehouse - _TC", serial_no=[serial_no]
+		)
 
 		with self.assertRaises(SerialNoDuplicateError) as context:
 			make_serial_batch_bundle(
 				{
 					"item_code": item_code,
 					"warehouse": "_Test Warehouse - _TC",
 					"voucher_type": "Stock Entry",
 					"posting_date": today(),
 					"posting_time": nowtime(),
 					"qty": 1,
 					"rate": 100,
 					"serial_nos": [serial_no],
 					"type_of_transaction": "Inward",
 					"do_not_submit": True,
 				}
 			)
 
 		self.assertIn("already delivered", str(context.exception).lower())
 
 		frappe.db.rollback()

953-999: Enhance test with additional assertions for return transaction verification.

The test correctly creates a return flow but only asserts that return_dn.name exists (line 996). Consider adding more comprehensive assertions to verify:

  • The serial number status after the return (should it remain "Delivered" or change?)
  • The warehouse quantity is updated correctly
  • The bundle was created successfully for the return transaction
 		return_dn = create_delivery_note(
 			item_code=item_code,
 			qty=-1,
 			rate=100,
 			warehouse="_Test Warehouse - _TC",
 			serial_no=[serial_no],
 			is_return=1,
 			return_against=dn.name,
 		)
 
 		self.assertTrue(return_dn.name)
+		
+		# Verify the return transaction was successful
+		self.assertEqual(return_dn.docstatus, 1, "Return delivery note should be submitted")
+		
+		# Verify serial number warehouse is updated back
+		sn_warehouse = frappe.db.get_value("Serial No", serial_no, "warehouse")
+		self.assertEqual(sn_warehouse, "_Test Warehouse - _TC", "Serial should be back in warehouse after return")
 
 		frappe.db.rollback()
📜 Review details

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4e9732a and 4bbd808.

📒 Files selected for processing (2)
  • erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py (32 hunks)
  • erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py (3)
erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py (1)
  • SerialNoDuplicateError (44-45)
erpnext/stock/doctype/item/test_item.py (1)
  • make_item (35-74)
erpnext/stock/doctype/delivery_note/test_delivery_note.py (1)
  • create_delivery_note (2667-2739)
erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py (3)
erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py (1)
  • on_doctype_update (348-351)
erpnext/stock/serial_batch_bundle.py (3)
  • get (958-959)
  • get_serial_nos_from_bundle (552-553)
  • get_batches_from_bundle (532-549)
erpnext/stock/utils.py (1)
  • get_combine_datetime (680-692)
🪛 Ruff (0.14.2)
erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py

231-231: Unpacked variable batches is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


356-357: try-except-pass detected, consider logging the exception

(S110)


356-356: Do not catch blind exception: Exception

(BLE001)


2257-2257: Unused function argument: kwargs

(ARG001)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Summary

if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate()
serial_nos, batches = self.get_serial_nos_for_validate()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix unused variable from tuple unpacking.

The batches variable is unpacked but never used in validate_serial_nos_inventory. Use _ to indicate it's intentionally unused.

Apply this diff:

 	def validate_serial_nos_inventory(self):
 		if not (self.has_serial_no and self.type_of_transaction == "Outward"):
 			return
 
 		if self.voucher_type == "Stock Reconciliation":
-			serial_nos, batches = self.get_serial_nos_for_validate()
+			serial_nos, _ = self.get_serial_nos_for_validate()
 		else:
 			serial_nos = [d.serial_no for d in self.entries if d.serial_no]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
serial_nos, batches = self.get_serial_nos_for_validate()
serial_nos, _ = self.get_serial_nos_for_validate()
🧰 Tools
🪛 Ruff (0.14.2)

231-231: Unpacked variable batches is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
around line 231, the tuple unpacking assigns serial_nos and batches but batches
is unused; replace the second variable with an underscore (e.g., serial_nos, _ =
self.get_serial_nos_for_validate()) to indicate the value is intentionally
ignored and remove the unused variable warning.
Comment on lines +332 to +360
def is_return_transaction(self):
"""
Check if this is a return transaction.
Return transactions legitimately reuse serial numbers.
"""
# Check if returned_against field is set
if self.returned_against:
return True

# Check if parent voucher is a return
if self.voucher_type in [
"Delivery Note",
"Sales Invoice",
"Purchase Invoice",
"Purchase Receipt",
"POS Invoice",
"Subcontracting Receipt",
]:
try:
voucher_details = frappe.db.get_value(
self.voucher_type, self.voucher_no, ["is_return", "return_against"], as_dict=True
)
if voucher_details and voucher_details.get("is_return"):
return True
except Exception:
pass

return False

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace broad exception handling with specific error handling or logging.

The try-except block catches all exceptions and silently passes (lines 350-357), which can hide legitimate errors like database connection issues or permission problems. This makes debugging difficult and violates best practices.

Since frappe.db.get_value returns None when the record doesn't exist, the try-except may not be necessary. If you need to handle specific errors, catch only those exceptions and log them:

 	def is_return_transaction(self):
 		"""
 		Check if this is a return transaction.
 		Return transactions legitimately reuse serial numbers.
 		"""
 		# Check if returned_against field is set
 		if self.returned_against:
 			return True
 
 		# Check if parent voucher is a return
 		if self.voucher_type in [
 			"Delivery Note",
 			"Sales Invoice",
 			"Purchase Invoice",
 			"Purchase Receipt",
 			"POS Invoice",
 			"Subcontracting Receipt",
 		]:
-			try:
-				voucher_details = frappe.db.get_value(
-					self.voucher_type, self.voucher_no, ["is_return", "return_against"], as_dict=True
-				)
-				if voucher_details and voucher_details.get("is_return"):
-					return True
-			except Exception:
-				pass
+			if not self.voucher_no:
+				return False
+				
+			voucher_details = frappe.db.get_value(
+				self.voucher_type, self.voucher_no, ["is_return", "return_against"], as_dict=True
+			)
+			if voucher_details and voucher_details.get("is_return"):
+				return True
 
 		return False
🧰 Tools
🪛 Ruff (0.14.2)

356-357: try-except-pass detected, consider logging the exception

(S110)


356-356: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
around lines 332-360, the broad try-except around frappe.db.get_value silently
swallows all errors; remove the unnecessary try-except (since get_value returns
None for missing records) so exceptions can propagate, or if you really need to
handle DB-specific failures catch only the specific exceptions (e.g., database
operational/connection errors or framework-specific exceptions) and log them
with context using frappe.logger or process logger instead of passing silently.
@rohitwaghchaure rohitwaghchaure self-assigned this Nov 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants