fix(stock): show physical serial and batch numbers in errors

This commit is contained in:
Mihir Kandoi
2026-09-09 09:56:45 +05:30
parent fca005c935
commit 445a30ba60
5 changed files with 257 additions and 31 deletions

View File

@@ -15,6 +15,7 @@ from frappe.query_builder.functions import Concat_ws, Max, Sum
from frappe.utils import (
cint,
cstr,
escape_html,
flt,
format_datetime,
get_datetime,
@@ -34,7 +35,8 @@ from erpnext.stock.serial_batch_bundle import (
get_batches_from_bundle,
)
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_number_entries
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, add_number_labels, resolve_number_entries
from erpnext.stock.valuation import FIFOValuation
@@ -170,7 +172,7 @@ class SerialandBatchBundle(Document):
"You cannot outward the following {0} as they are either Delivered, Inactive or located in a different warehouse."
).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No"))
msg += "<hr>"
msg += ", ".join(sn for sn in invalid_serial_nos)
msg += format_serial_batch_numbers("Serial No", invalid_serial_nos)
frappe.throw(msg)
def validate_voucher_detail_no(self):
@@ -231,7 +233,7 @@ class SerialandBatchBundle(Document):
_(
"You cannot process the serial number {0} as it has already been used in the SABB {1}. {2} If you want to inward the same serial number multiple times, then enable 'Allow existing Serial No to be Manufactured/Received again' in the {3}"
).format(
row.serial_no,
format_serial_batch_numbers("Serial No", [row.serial_no]),
get_link_to_form("Serial and Batch Bundle", row.parent),
note,
get_link_to_form("Stock Settings", "Stock Settings"),
@@ -311,10 +313,11 @@ class SerialandBatchBundle(Document):
for serial_no in serial_nos:
if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse:
serial_number = format_serial_batch_numbers("Serial No", [serial_no])
reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse)
if reservation:
self.throw_error_message(
f"Serial No {bold(serial_no)} is in warehouse {bold(self.warehouse)}"
f"Serial No {bold(serial_number)} is in warehouse {bold(self.warehouse)}"
f" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}"
f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}."
f" Please use an unreserved serial number or cancel the reservation.",
@@ -322,7 +325,9 @@ class SerialandBatchBundle(Document):
)
else:
self.throw_error_message(
f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.",
_("Serial No {0} is not present in the warehouse {1}.").format(
bold(serial_number), bold(self.warehouse)
),
SerialNoWarehouseError,
)
@@ -360,7 +365,9 @@ class SerialandBatchBundle(Document):
for data in available_serial_nos:
if data.serial_no in serial_nos:
self.throw_error_message(
f"Serial No {bold(data.serial_no)} is already present in the warehouse {bold(data.warehouse)}.",
_("Serial No {0} is already present in the warehouse {1}.").format(
bold(format_serial_batch_numbers("Serial No", [data.serial_no])), bold(data.warehouse)
),
SerialNoDuplicateError,
)
@@ -379,13 +386,13 @@ class SerialandBatchBundle(Document):
frappe.throw(
_(
"Serial No {0} is already Delivered. You cannot use it again in Manufacture / Repack entry."
).format(bold(serial_nos[0]))
).format(bold(format_serial_batch_numbers("Serial No", [serial_nos[0]])))
)
else:
frappe.throw(
_(
"Serial Nos {0} are already Delivered. You cannot use them again in Manufacture / Repack entry."
).format(bold(", ".join(serial_nos)))
).format(bold(format_serial_batch_numbers("Serial No", serial_nos)))
)
def throw_error_message(self, message, exception=frappe.ValidationError):
@@ -534,14 +541,22 @@ class SerialandBatchBundle(Document):
self.throw_error_message(
_(
"Serial No {0} is not present in the {1} {2}, hence you can't return it against the {1} {2}"
).format(bold(row.serial_no), self.voucher_type, bold(return_against))
).format(
bold(format_serial_batch_numbers("Serial No", [row.serial_no])),
self.voucher_type,
bold(return_against),
)
)
if row.batch_no and row.batch_no not in original_inv_details["batches"]:
self.throw_error_message(
_(
"Batch No {0} is not present in the original {1} {2}, hence you can't return it against the {1} {2}"
).format(bold(row.batch_no), self.voucher_type, bold(return_against))
).format(
bold(format_serial_batch_numbers("Batch", [row.batch_no])),
self.voucher_type,
bold(return_against),
)
)
def get_valuation_rate_for_return_entry(self, return_against):
@@ -773,7 +788,10 @@ class SerialandBatchBundle(Document):
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
frappe.throw(
_("Batch No {0} of Item {1} has negative stock of quantity {2} in the warehouse {3}").format(
bold(batch_no), bold(self.item_code), bold(available_qty), self.warehouse
bold(format_serial_batch_numbers("Batch", [batch_no])),
bold(self.item_code),
bold(available_qty),
self.warehouse,
),
BatchNegativeStockError,
)
@@ -1093,11 +1111,12 @@ class SerialandBatchBundle(Document):
msg += "<br><br><ul>"
add_number_labels(future_entries)
for d in future_entries:
if self.has_serial_no:
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
msg += f"<li>{escape_html(d.serial_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
else:
msg += f"<li>{d.batch_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
msg += f"<li>{escape_html(d.batch_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
msg += "</li></ul>"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
@@ -1283,7 +1302,7 @@ class SerialandBatchBundle(Document):
frappe.throw(
_("At row {0}: Qty is mandatory for the batch {1}").format(
bold(row.idx), bold(row.batch_no)
bold(row.idx), bold(format_serial_batch_numbers("Batch", [row.batch_no]))
)
)
@@ -1334,7 +1353,10 @@ class SerialandBatchBundle(Document):
for serial_no, batch_no in serial_batches.items():
if correct_batches.get(serial_no) and correct_batches.get(serial_no) != batch_no:
self.throw_error_message(
f"Serial No {bold(serial_no)} does not belong to Batch No {bold(batch_no)}"
_("Serial No {0} does not belong to Batch No {1}").format(
bold(format_serial_batch_numbers("Serial No", [serial_no])),
bold(format_serial_batch_numbers("Batch", [batch_no])),
)
)
def validate_incorrect_serial_nos(self, serial_nos):
@@ -1345,9 +1367,13 @@ class SerialandBatchBundle(Document):
)
if incorrect_serial_nos:
incorrect_serial_nos = ", ".join([d.name for d in incorrect_serial_nos])
incorrect_serial_nos = format_serial_batch_numbers(
"Serial No", [d.name for d in incorrect_serial_nos]
)
self.throw_error_message(
f"Serial Nos {bold(incorrect_serial_nos)} does not belong to Item {bold(self.item_code)}"
_("Serial Nos {0} does not belong to Item {1}").format(
bold(incorrect_serial_nos), bold(self.item_code)
)
)
def validate_incorrect_batch_nos(self, batch_nos):
@@ -1356,9 +1382,11 @@ class SerialandBatchBundle(Document):
)
if incorrect_batch_nos:
incorrect_batch_nos = ", ".join([d.name for d in incorrect_batch_nos])
incorrect_batch_nos = format_serial_batch_numbers("Batch", [d.name for d in incorrect_batch_nos])
self.throw_error_message(
f"Batch Nos {bold(incorrect_batch_nos)} does not belong to Item {bold(self.item_code)}"
_("Batch Nos {0} does not belong to Item {1}").format(
bold(incorrect_batch_nos), bold(self.item_code)
)
)
def validate_serial_and_batch_no_for_returned(self):
@@ -1400,13 +1428,17 @@ class SerialandBatchBundle(Document):
if serial_nos:
if not set(current_serial_nos).issubset(set(serial_nos)):
self.throw_error_message(
f"Serial Nos {bold(', '.join(serial_nos))} are not part of the original document."
_("Serial Nos {0} are not part of the original document.").format(
bold(format_serial_batch_numbers("Serial No", serial_nos))
)
)
if batches:
if not set(current_batches).issubset(set(batches)):
self.throw_error_message(
f"Batch Nos {bold(', '.join(batches))} are not part of the original document."
_("Batch Nos {0} are not part of the original document.").format(
bold(format_serial_batch_numbers("Batch", batches))
)
)
def get_orignal_document_data(self):
@@ -1434,12 +1466,18 @@ class SerialandBatchBundle(Document):
if serial_nos:
for key, value in collections.Counter(serial_nos).items():
if value > 1:
self.throw_error_message(f"Duplicate Serial No {key} found")
self.throw_error_message(
_("Duplicate Serial No {0} found").format(
format_serial_batch_numbers("Serial No", [key])
)
)
if batch_nos:
for key, value in collections.Counter(batch_nos).items():
if value > 1:
self.throw_error_message(f"Duplicate Batch No {key} found")
self.throw_error_message(
_("Duplicate Batch No {0} found").format(format_serial_batch_numbers("Batch", [key]))
)
def before_cancel(self):
self.delink_serial_and_batch_bundle()
@@ -1632,7 +1670,9 @@ class SerialandBatchBundle(Document):
self.validate_negative_batch(batch_no, available_batches[batch_no])
self.throw_error_message(
f"Batch {bold(batch_no)} is not available in the selected warehouse {self.warehouse}"
_("Batch {0} is not available in the selected warehouse {1}").format(
bold(format_serial_batch_numbers("Batch", [batch_no])), self.warehouse
)
)
def on_cancel(self):
@@ -1711,7 +1751,7 @@ class SerialandBatchBundle(Document):
"However, enabling this setting may lead to negative stock in the system. "
"So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."
).format(
bold(batch_no),
bold(format_serial_batch_numbers("Batch", [batch_no])),
bold(self.item_code),
bold(self.warehouse),
date_msg,
@@ -2747,7 +2787,11 @@ def get_reserved_serial_nos_for_voucher(kwargs, reserved_entries, reserved_vouch
frappe.throw(
_(
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction."
).format(bold(entry.serial_no), entry.voucher_type, bold(entry.voucher_no)),
).format(
bold(format_serial_batch_numbers("Serial No", [entry.serial_no])),
entry.voucher_type,
bold(entry.voucher_no),
),
title=_("Serial No Reserved"),
)

View File

@@ -16,7 +16,7 @@ from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos
from erpnext.stock.serial_batch_display import SerialBatchReference
from erpnext.stock.serial_batch_display import SerialBatchReference, format_serial_batch_numbers
class StockFreezeError(frappe.ValidationError):
@@ -210,7 +210,8 @@ class StockLedgerEntry(SerialBatchReference):
if mismatches:
frappe.throw(
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format(
frappe.bold(serial_no), frappe.bold(", ".join(mismatches))
frappe.bold(format_serial_batch_numbers("Serial No", [serial_no])),
frappe.bold(", ".join(mismatches)),
),
title=_("Incorrect Inventory Dimension"),
exc=SerialNoInventoryDimensionError,
@@ -383,7 +384,9 @@ class StockLedgerEntry(SerialBatchReference):
if expiry_date:
if getdate(self.posting_date) > getdate(expiry_date):
frappe.throw(
_("Batch {0} of Item {1} has expired.").format(self.batch_no, self.item_code)
_("Batch {0} of Item {1} has expired.").format(
format_serial_batch_numbers("Batch", [self.batch_no]), self.item_code
)
)
def validate_and_set_fiscal_year(self):

View File

@@ -12,6 +12,7 @@ from erpnext.stock.deprecated_serial_batch import (
DeprecatedBatchNoValuation,
DeprecatedSerialNoValuation,
)
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
from erpnext.stock.valuation import round_off_if_near_zero
CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = (
@@ -1400,7 +1401,11 @@ class SerialBatchCreation:
)
for name in self.serial_nos:
if name not in existing:
frappe.throw(_("Serial No {0} does not exist for Item {1}").format(name, self.item_code))
frappe.throw(
_("Serial No {0} does not exist for Item {1}").format(
format_serial_batch_numbers("Serial No", [name]), self.item_code
)
)
def set_serial_batch_entries(self, doc):
incoming_rate = self.get("incoming_rate")
@@ -1598,7 +1603,10 @@ def throw_negative_batch_validation(batch_no, qty):
frappe.throw(
_(
"The Batch {0} has negative batch quantity {1}. To fix this, go to the batch and click on Recalculate Batch Qty. If the issue still persists, create an inward entry."
).format(bold(get_link_to_form("Batch", batch_no)), bold(qty)),
).format(
bold(get_link_to_form("Batch", batch_no, format_serial_batch_numbers("Batch", [batch_no]))),
bold(qty),
),
title=_("Negative Stock Error"),
)

View File

@@ -11,6 +11,11 @@ from frappe.utils import escape_html
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
def format_serial_batch_numbers(doctype: str, names: list[str]) -> str:
labels = SerialBatchIdentity(doctype).labels(names)
return ", ".join(escape_html(labels.get(name) or name) for name in names)
def with_serial_batch_numbers(execute):
@wraps(execute)
def wrapped(*args, **kwargs):

View File

@@ -0,0 +1,166 @@
import frappe
from frappe.utils import add_days, escape_html, now_datetime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
BatchNegativeStockError,
SerialNoDuplicateError,
SerialNoExistsInFutureTransactionError,
SerialNoWarehouseError,
)
from erpnext.stock.serial_batch_bundle import throw_negative_batch_validation
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
from erpnext.tests.utils import ERPNextTestSuite
class TestSerialBatchMessages(ERPNextTestSuite):
def setUp(self):
super().setUp()
self.item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
self.batch_number = "Batch<&>"
self.serial_number = "Serial<&>"
self.batch = SerialBatchIdentity("Batch").resolve(self.item.name, [self.batch_number], create=True)[0]
self.serial = SerialBatchIdentity("Serial No").resolve(
self.item.name,
[self.serial_number],
create=True,
defaults={"company": "_Test Company", "batch_no": self.batch},
)[0]
self.bundle = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"item_code": self.item.name,
"has_serial_no": 1,
"has_batch_no": 1,
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"warehouse": "_Test Warehouse - _TC",
"entries": [{"serial_no": self.serial, "batch_no": self.batch, "qty": 1}],
}
)
def test_duplicate_receipt_shows_serial_number(self):
args = {
"item_code": self.item.name,
"qty": 1,
"serial_no": self.serial,
"batch_no": self.batch,
"use_serial_batch_fields": 1,
}
make_purchase_receipt(**args)
receipt = make_purchase_receipt(**args, do_not_submit=True)
with self.assertRaises(SerialNoDuplicateError) as error:
receipt.submit()
self.assert_number_message(error, self.serial, self.serial_number)
self.assertIn("already present in the warehouse", str(error.exception))
self.assertEqual(receipt.items[0].serial_no, self.serial)
self.assertEqual(frappe.db.get_value("Serial No", self.serial, "warehouse"), self.bundle.warehouse)
def test_missing_serial_inventory_shows_number(self):
self.bundle.type_of_transaction = "Outward"
with self.assertRaises(SerialNoWarehouseError) as error:
self.bundle.validate_serial_nos_inventory()
self.assert_number_message(error, self.serial, self.serial_number)
def test_duplicate_entries_show_numbers(self):
for doctype, field, name, number in self.number_cases():
with self.subTest(doctype=doctype):
self.bundle.set("entries", [{field: name, "qty": 1}, {field: name, "qty": 1}])
with self.assertRaises(frappe.ValidationError) as error:
self.bundle.validate_duplicate_serial_and_batch_no()
self.assert_number_message(error, name, number)
self.assertEqual([row.get(field) for row in self.bundle.entries], [name, name])
def test_wrong_item_shows_numbers(self):
self.bundle.item_code = make_item().name
for doctype, _field, name, number in self.number_cases():
with self.subTest(doctype=doctype):
validate = (
self.bundle.validate_incorrect_serial_nos
if doctype == "Serial No"
else self.bundle.validate_incorrect_batch_nos
)
with self.assertRaises(frappe.ValidationError) as error:
validate([name])
self.assert_number_message(error, name, number)
def test_return_error_shows_numbers(self):
for doctype, field, name, number in self.number_cases():
with self.subTest(doctype=doctype):
with self.assertRaises(frappe.ValidationError) as error:
self.bundle.validate_returned_serial_batch_no(
"Original Receipt", frappe._dict({field: name}), {"serial_nos": [], "batches": []}
)
self.assert_number_message(error, name, number)
def test_negative_stock_shows_batch_number(self):
with self.assertRaises(BatchNegativeStockError) as error:
self.bundle.validate_negative_batch(self.batch, -1)
self.assert_number_message(error, self.batch, self.batch_number)
def test_expired_batch_shows_number(self):
frappe.db.set_value("Batch", self.batch, "expiry_date", add_days(today(), -1))
entry = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"batch_no": self.batch,
"item_code": self.item.name,
"voucher_type": "Delivery Note",
"actual_qty": -1,
"posting_date": today(),
}
)
with self.assertRaises(frappe.ValidationError) as error:
entry.validate_batch()
self.assert_number_message(error, self.batch, self.batch_number)
self.assertEqual(entry.batch_no, self.batch)
def test_serial_batch_mismatch_shows_both_numbers(self):
batch_number = "Other Batch<&>"
batch = SerialBatchIdentity("Batch").resolve(self.item.name, [batch_number], create=True)[0]
with self.assertRaises(frappe.ValidationError) as error:
self.bundle.validate_serial_batch_no({self.serial: batch})
self.assert_number_message(error, self.serial, self.serial_number)
self.assert_number_message(error, batch, batch_number)
def test_future_transaction_shows_serial_number_and_document_link(self):
receipt = make_purchase_receipt(
item_code=self.item.name, qty=1, serial_no=[self.serial], batch_no=self.batch
)
self.bundle.name = "new-bundle"
self.bundle.posting_datetime = add_days(now_datetime(), -1)
with self.assertRaises(SerialNoExistsInFutureTransactionError) as error:
self.bundle.check_future_entries_exists()
self.assert_number_message(error, self.serial, self.serial_number)
self.assertIn(f'/purchase-receipt/{receipt.name}"', str(error.exception))
def test_legacy_and_missing_records_keep_the_number(self):
frappe.db.set_value("Serial No", self.serial, "serial_no", self.serial)
for name in (self.serial, "Missing<&>"):
with self.subTest(name=name):
self.bundle.set("entries", [{"serial_no": name}, {"serial_no": name}])
with self.assertRaises(frappe.ValidationError) as error:
self.bundle.validate_duplicate_serial_and_batch_no()
self.assertIn(escape_html(name), str(error.exception))
def test_batch_error_link_keeps_id_and_displays_number(self):
with self.assertRaises(frappe.ValidationError) as error:
throw_negative_batch_validation(self.batch, -1)
message = str(error.exception)
self.assertIn(f'/batch/{self.batch}"', message)
self.assertIn(f">{escape_html(self.batch_number)}</a>", message)
self.assertNotIn(f">{self.batch}</a>", message)
self.assertNotIn(self.batch_number, message)
def number_cases(self):
return [
("Serial No", "serial_no", self.serial, self.serial_number),
("Batch", "batch_no", self.batch, self.batch_number),
]
def assert_number_message(self, error, name, number):
message = str(error.exception)
self.assertIn(escape_html(number), message)
self.assertNotIn(name, message)
self.assertNotIn(number, message)