diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 2ea3645cb0b..c00d1809868 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -2,13 +2,15 @@ # License: GNU General Public License v3. See license.txt +from math import isfinite from typing import Any import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, flt, get_link_to_form, get_number_format_info +from frappe.utils import cint, flt, get_link_to_form +from frappe.utils.number_format import NUMBER_FORMAT_MAP, NumberFormat from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( get_template_details, @@ -283,23 +285,33 @@ class QualityInspection(Document): break def validate_reading_number_format(self): - """Reject readings written in a different number format than the user's. + """Reject newly entered readings that are not numbers in the user's format. They would otherwise be misread rather than refused, silently rejecting an - inspection whose readings are in fact within the acceptance range.""" + inspection whose readings are in fact within the acceptance range. Readings + already stored are left alone, so a document entered by a user in one locale + stays saveable and submittable by a user in another.""" number_format = get_reading_number_format() - decimal_str, comma_str, _precision = get_number_format_info(number_format) + decimal_str, comma_str = get_reading_separators(number_format) + before_save = self.get_doc_before_save() for reading in self.readings: - if not cint(reading.numeric): + if not cint(reading.numeric) or cint(reading.manual_inspection): continue + stored = before_save and before_save.get("readings", {"name": reading.name}) + stored = stored[0] if stored else None + for i in range(1, 11): - value = reading.get("reading_" + str(i)) + field = "reading_" + str(i) + value = reading.get(field) if value is None or not value.strip(): continue - if not is_valid_number(value, decimal_str, comma_str): + if stored and stored.get(field) == value: + continue + + if parse_reading(value, decimal_str, comma_str) is None: frappe.throw( _( "Row #{0}: Reading {1} {2} is not a valid number in the {3} number format. Use {4} as the decimal separator." @@ -307,7 +319,7 @@ class QualityInspection(Document): reading.idx, i, frappe.bold(value), - frappe.bold(number_format), + frappe.bold(number_format.string), frappe.bold(decimal_str), ), title=_("Invalid Reading"), @@ -543,42 +555,54 @@ def make_quality_inspection(source_name: str, target_doc: str | dict | Document return doc -def get_reading_number_format() -> str: +def get_reading_number_format() -> NumberFormat: """Number format the user enters readings in. User defaults fall back to the global default, so this is the same format the user's desk formats numbers with.""" - return frappe.defaults.get_user_default("number_format") or "#,###.##" + number_format = frappe.defaults.get_user_default("number_format") + if number_format not in NUMBER_FORMAT_MAP: + number_format = "#,###.##" + + return NumberFormat.from_string(number_format) -def is_valid_number(num: str, decimal_str: str, comma_str: str) -> bool: - num = num.strip().lstrip("+-") - integer_part, fraction = num, "" +def get_reading_separators(number_format: NumberFormat) -> tuple[str, str]: + """Decimal and thousands separator a reading may be written with. - if decimal_str: - if num.count(decimal_str) > 1: - return False - integer_part, _, fraction = num.partition(decimal_str) + A format with no decimal separator still has to accept decimal readings, so it + falls back to a dot and gives up any grouping that would collide with it.""" + decimal_str = number_format.decimal_separator or "." + comma_str = number_format.thousands_separator - if fraction and not fraction.isdigit(): - return False + return decimal_str, "" if comma_str == decimal_str else comma_str - if not integer_part: - return bool(fraction) + +def parse_reading(value: str, decimal_str: str, comma_str: str) -> float | None: + """Reading as a float, or None when it is not a number in that format.""" + value = value.strip() + integer_part = value.partition(decimal_str)[0] if comma_str and comma_str in integer_part: groups = integer_part.split(comma_str) - if any(not group.isdigit() for group in groups): - return False + lead = groups[0][1:] if groups[0][:1] in ("+", "-") else groups[0] + if not 1 <= len(lead) <= 3 or len(groups[-1]) != 3: + return None - # the group just before the decimal separator is always 3 digits long - if not 1 <= len(groups[0]) <= 3 or len(groups[-1]) != 3: - return False + if any(len(group) not in (2, 3) for group in groups[1:-1]): + return None - # 3 digits per group, or 2 in the indian format - return all(len(group) in (2, 3) for group in groups[1:-1]) + value = value.replace(comma_str, "") - return integer_part.isdigit() + if decimal_str != ".": + value = value.replace(decimal_str, ".") + + try: + number = float(value) + except ValueError: + return None + + return number if isfinite(number) else None def parse_float(num: str) -> float: @@ -586,13 +610,6 @@ def parse_float(num: str) -> float: is representation in user's prefered number format instead of machine readable format. This function converts them to machine readable format.""" - number_format = get_reading_number_format() - decimal_str, comma_str, _number_format_precision = get_number_format_info(number_format) + decimal_str, comma_str = get_reading_separators(get_reading_number_format()) - if comma_str: - num = num.replace(comma_str, "") - - if decimal_str and decimal_str != ".": - num = num.replace(decimal_str, ".") - - return flt(num) + return flt(parse_reading(num, decimal_str, comma_str))