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 ( from frappe.utils import (
cint, cint,
cstr, cstr,
escape_html,
flt, flt,
format_datetime, format_datetime,
get_datetime, get_datetime,
@@ -34,7 +35,8 @@ from erpnext.stock.serial_batch_bundle import (
get_batches_from_bundle, 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_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 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." "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")) ).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No"))
msg += "<hr>" msg += "<hr>"
msg += ", ".join(sn for sn in invalid_serial_nos) msg += format_serial_batch_numbers("Serial No", invalid_serial_nos)
frappe.throw(msg) frappe.throw(msg)
def validate_voucher_detail_no(self): 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}" "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( ).format(
row.serial_no, format_serial_batch_numbers("Serial No", [row.serial_no]),
get_link_to_form("Serial and Batch Bundle", row.parent), get_link_to_form("Serial and Batch Bundle", row.parent),
note, note,
get_link_to_form("Stock Settings", "Stock Settings"), get_link_to_form("Stock Settings", "Stock Settings"),
@@ -311,10 +313,11 @@ class SerialandBatchBundle(Document):
for serial_no in serial_nos: for serial_no in serial_nos:
if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse: 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) reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse)
if reservation: if reservation:
self.throw_error_message( 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" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}"
f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}." f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}."
f" Please use an unreserved serial number or cancel the reservation.", f" Please use an unreserved serial number or cancel the reservation.",
@@ -322,7 +325,9 @@ class SerialandBatchBundle(Document):
) )
else: else:
self.throw_error_message( 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, SerialNoWarehouseError,
) )
@@ -360,7 +365,9 @@ class SerialandBatchBundle(Document):
for data in available_serial_nos: for data in available_serial_nos:
if data.serial_no in serial_nos: if data.serial_no in serial_nos:
self.throw_error_message( 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, SerialNoDuplicateError,
) )
@@ -379,13 +386,13 @@ class SerialandBatchBundle(Document):
frappe.throw( frappe.throw(
_( _(
"Serial No {0} is already Delivered. You cannot use it again in Manufacture / Repack entry." "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: else:
frappe.throw( frappe.throw(
_( _(
"Serial Nos {0} are already Delivered. You cannot use them again in Manufacture / Repack entry." "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): def throw_error_message(self, message, exception=frappe.ValidationError):
@@ -534,14 +541,22 @@ class SerialandBatchBundle(Document):
self.throw_error_message( self.throw_error_message(
_( _(
"Serial No {0} is not present in the {1} {2}, hence you can't return it against the {1} {2}" "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"]: if row.batch_no and row.batch_no not in original_inv_details["batches"]:
self.throw_error_message( 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}" "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): 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): if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
frappe.throw( frappe.throw(
_("Batch No {0} of Item {1} has negative stock of quantity {2} in the warehouse {3}").format( _("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, BatchNegativeStockError,
) )
@@ -1093,11 +1111,12 @@ class SerialandBatchBundle(Document):
msg += "<br><br><ul>" msg += "<br><br><ul>"
add_number_labels(future_entries)
for d in future_entries: for d in future_entries:
if self.has_serial_no: 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: 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>" msg += "</li></ul>"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError) frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
@@ -1283,7 +1302,7 @@ class SerialandBatchBundle(Document):
frappe.throw( frappe.throw(
_("At row {0}: Qty is mandatory for the batch {1}").format( _("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(): for serial_no, batch_no in serial_batches.items():
if correct_batches.get(serial_no) and correct_batches.get(serial_no) != batch_no: if correct_batches.get(serial_no) and correct_batches.get(serial_no) != batch_no:
self.throw_error_message( 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): def validate_incorrect_serial_nos(self, serial_nos):
@@ -1345,9 +1367,13 @@ class SerialandBatchBundle(Document):
) )
if incorrect_serial_nos: 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( 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): def validate_incorrect_batch_nos(self, batch_nos):
@@ -1356,9 +1382,11 @@ class SerialandBatchBundle(Document):
) )
if incorrect_batch_nos: 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( 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): def validate_serial_and_batch_no_for_returned(self):
@@ -1400,13 +1428,17 @@ class SerialandBatchBundle(Document):
if serial_nos: if serial_nos:
if not set(current_serial_nos).issubset(set(serial_nos)): if not set(current_serial_nos).issubset(set(serial_nos)):
self.throw_error_message( 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 batches:
if not set(current_batches).issubset(set(batches)): if not set(current_batches).issubset(set(batches)):
self.throw_error_message( 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): def get_orignal_document_data(self):
@@ -1434,12 +1466,18 @@ class SerialandBatchBundle(Document):
if serial_nos: if serial_nos:
for key, value in collections.Counter(serial_nos).items(): for key, value in collections.Counter(serial_nos).items():
if value > 1: 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: if batch_nos:
for key, value in collections.Counter(batch_nos).items(): for key, value in collections.Counter(batch_nos).items():
if value > 1: 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): def before_cancel(self):
self.delink_serial_and_batch_bundle() self.delink_serial_and_batch_bundle()
@@ -1632,7 +1670,9 @@ class SerialandBatchBundle(Document):
self.validate_negative_batch(batch_no, available_batches[batch_no]) self.validate_negative_batch(batch_no, available_batches[batch_no])
self.throw_error_message( 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): def on_cancel(self):
@@ -1711,7 +1751,7 @@ class SerialandBatchBundle(Document):
"However, enabling this setting may lead to negative stock in the system. " "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." "So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."
).format( ).format(
bold(batch_no), bold(format_serial_batch_numbers("Batch", [batch_no])),
bold(self.item_code), bold(self.item_code),
bold(self.warehouse), bold(self.warehouse),
date_msg, date_msg,
@@ -2747,7 +2787,11 @@ def get_reserved_serial_nos_for_voucher(kwargs, reserved_entries, reserved_vouch
frappe.throw( frappe.throw(
_( _(
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction." "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"), 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.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.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_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): class StockFreezeError(frappe.ValidationError):
@@ -210,7 +210,8 @@ class StockLedgerEntry(SerialBatchReference):
if mismatches: if mismatches:
frappe.throw( frappe.throw(
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format( _("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"), title=_("Incorrect Inventory Dimension"),
exc=SerialNoInventoryDimensionError, exc=SerialNoInventoryDimensionError,
@@ -383,7 +384,9 @@ class StockLedgerEntry(SerialBatchReference):
if expiry_date: if expiry_date:
if getdate(self.posting_date) > getdate(expiry_date): if getdate(self.posting_date) > getdate(expiry_date):
frappe.throw( 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): def validate_and_set_fiscal_year(self):

View File

@@ -12,6 +12,7 @@ from erpnext.stock.deprecated_serial_batch import (
DeprecatedBatchNoValuation, DeprecatedBatchNoValuation,
DeprecatedSerialNoValuation, DeprecatedSerialNoValuation,
) )
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
from erpnext.stock.valuation import round_off_if_near_zero from erpnext.stock.valuation import round_off_if_near_zero
CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = ( CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = (
@@ -1400,7 +1401,11 @@ class SerialBatchCreation:
) )
for name in self.serial_nos: for name in self.serial_nos:
if name not in existing: 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): def set_serial_batch_entries(self, doc):
incoming_rate = self.get("incoming_rate") incoming_rate = self.get("incoming_rate")
@@ -1598,7 +1603,10 @@ def throw_negative_batch_validation(batch_no, qty):
frappe.throw( 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." "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"), title=_("Negative Stock Error"),
) )

View File

@@ -11,6 +11,11 @@ from frappe.utils import escape_html
from erpnext.stock.serial_batch_identity import SerialBatchIdentity 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): def with_serial_batch_numbers(execute):
@wraps(execute) @wraps(execute)
def wrapped(*args, **kwargs): 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)