mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
fix(stock): show physical serial and batch numbers in errors
This commit is contained in:
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
166
erpnext/stock/tests/test_serial_batch_messages.py
Normal file
166
erpnext/stock/tests/test_serial_batch_messages.py
Normal 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)
|
||||||
Reference in New Issue
Block a user