From 445a30ba60c7390ebfe72c68e54f10df7bbc74cf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 9 Sep 2026 09:56:45 +0530 Subject: [PATCH] fix(stock): show physical serial and batch numbers in errors --- .../serial_and_batch_bundle.py | 96 +++++++--- .../stock_ledger_entry/stock_ledger_entry.py | 9 +- erpnext/stock/serial_batch_bundle.py | 12 +- erpnext/stock/serial_batch_display.py | 5 + .../stock/tests/test_serial_batch_messages.py | 166 ++++++++++++++++++ 5 files changed, 257 insertions(+), 31 deletions(-) create mode 100644 erpnext/stock/tests/test_serial_batch_messages.py 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 c98a4f0bff0..21cc0feb57c 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 @@ -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 += "
" - 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 += "

" 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"), ) diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index c65bec64b3c..062cef7f21d 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -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): diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 0a366b61934..809c91d4535 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -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"), ) diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py index f7dffb7b990..5630a4b59e0 100644 --- a/erpnext/stock/serial_batch_display.py +++ b/erpnext/stock/serial_batch_display.py @@ -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): diff --git a/erpnext/stock/tests/test_serial_batch_messages.py b/erpnext/stock/tests/test_serial_batch_messages.py new file mode 100644 index 00000000000..e8623a1996e --- /dev/null +++ b/erpnext/stock/tests/test_serial_batch_messages.py @@ -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)}", message) + self.assertNotIn(f">{self.batch}", 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)