From e74c0a3cdbc644f2113d4e64519d51950e0c698d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:51:46 +0530 Subject: [PATCH 1/5] fix(stock): read quality inspection readings in the user's number format readings are Data fields, so they are parsed server side. parse_float only swapped the separators for "#.###,##", so in the space grouped "# ###,##" (polish) a reading of 1,15 was read as 115, fell outside the acceptance range and silently rejected the inspection. strip whatever the group separator is and normalise whatever the decimal separator is instead. it also read the global number format, while the desk formats numbers with the user's own. a user whose locale differs from the site therefore typed readings in a format the server did not parse them with. read the user default, which falls back to the global one. a reading that is not a valid number in that format is now rejected with an error instead of being read as a different number. --- .../quality_inspection/quality_inspection.py | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index c1d2d831826..2ea3645cb0b 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -84,6 +84,7 @@ class QualityInspection(Document): reading.status = "Accepted" if self.readings: + self.validate_reading_number_format() self.inspect_and_set_status() self.validate_inspection_required() @@ -281,6 +282,37 @@ class QualityInspection(Document): ) break + def validate_reading_number_format(self): + """Reject readings written in a different number format than the user's. + + They would otherwise be misread rather than refused, silently rejecting an + inspection whose readings are in fact within the acceptance range.""" + number_format = get_reading_number_format() + decimal_str, comma_str, _precision = get_number_format_info(number_format) + + for reading in self.readings: + if not cint(reading.numeric): + continue + + for i in range(1, 11): + value = reading.get("reading_" + str(i)) + if value is None or not value.strip(): + continue + + if not is_valid_number(value, decimal_str, comma_str): + frappe.throw( + _( + "Row #{0}: Reading {1} {2} is not a valid number in the {3} number format. Use {4} as the decimal separator." + ).format( + reading.idx, + i, + frappe.bold(value), + frappe.bold(number_format), + frappe.bold(decimal_str), + ), + title=_("Invalid Reading"), + ) + def set_status_based_on_acceptance_values(self, reading): if not cint(reading.numeric): reading_value = reading.get("reading_value") or "" @@ -511,17 +543,56 @@ def make_quality_inspection(source_name: str, target_doc: str | dict | Document return doc +def get_reading_number_format() -> str: + """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 "#,###.##" + + +def is_valid_number(num: str, decimal_str: str, comma_str: str) -> bool: + num = num.strip().lstrip("+-") + integer_part, fraction = num, "" + + if decimal_str: + if num.count(decimal_str) > 1: + return False + integer_part, _, fraction = num.partition(decimal_str) + + if fraction and not fraction.isdigit(): + return False + + if not integer_part: + return bool(fraction) + + 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 + + # 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 + + # 3 digits per group, or 2 in the indian format + return all(len(group) in (2, 3) for group in groups[1:-1]) + + return integer_part.isdigit() + + def parse_float(num: str) -> float: """Since reading_# fields are `Data` field they might contain number which is representation in user's prefered number format instead of machine readable format. This function converts them to machine readable format.""" - number_format = frappe.db.get_default("number_format") or "#,###.##" + number_format = get_reading_number_format() decimal_str, comma_str, _number_format_precision = get_number_format_info(number_format) - if decimal_str == "," and comma_str == ".": - num = num.replace(",", "#$") - num = num.replace(".", ",") - num = num.replace("#$", ".") + if comma_str: + num = num.replace(comma_str, "") + + if decimal_str and decimal_str != ".": + num = num.replace(decimal_str, ".") return flt(num) From 3752be809f72531df0fc2deb1a9e06d7221629a2 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:52:03 +0530 Subject: [PATCH 2/5] test(stock): drop non numeric reading from formula based quality inspection a numeric reading of "random text" was read as 0 and pulled the mean from 0.6 down to 0.4, which the test then asserted as accepted. such a reading is now rejected outright, and the test is about formula evaluation, so drop the row. its assertions are unchanged. --- .../stock/doctype/quality_inspection/test_quality_inspection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 9445e5da94f..fc0ec9c49c2 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -108,7 +108,6 @@ class TestQualityInspection(ERPNextTestSuite): "acceptance_formula": "mean < 0.9", "reading_1": "0.5", "reading_2": "0.7", - "reading_3": "random text", # check if random string input causes issues }, { "specification": "Calcium Content", # non-numeric reading From b1f188146ea2554dc3fd85b5ef75f7fcaa834b17 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:52:13 +0530 Subject: [PATCH 3/5] test(stock): cover quality inspection readings in every number format covers the reported case, a 1,15 reading in the space grouped "# ###,##" format, which was read as 115 and rejected. also covers the dot grouped comma format, and asserts that a reading written with the wrong separator, or one that is not a number at all, is now rejected with an error rather than read as a different value. --- .../test_quality_inspection.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index fc0ec9c49c2..433004d1657 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -251,6 +251,90 @@ class TestQualityInspection(ERPNextTestSuite): qa.delete() dn.delete() + def test_non_numeric_reading(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + # text in a numeric reading was read as 0, silently skewing the mean + readings = [ + {"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "random text"} + ] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + + self.assertRaises(frappe.ValidationError, qa.save) + + dn.delete() + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#.###,##"}) + def test_reading_in_comma_decimal_number_format(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() + + # 1,15 is 1.15 in this format, which is within the acceptance range + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.status, "Accepted") + + qa.delete() + dn.delete() + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "# ###,##"}) + def test_reading_in_space_grouped_number_format(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + # this format is comma decimal but space grouped, so the separators were not + # swapped at all and 1,15 was read as 115 and rejected + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() + + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.status, "Accepted") + + qa.delete() + dn.delete() + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#.###,##"}) + def test_reading_in_wrong_decimal_number_format(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + # a dot is the group separator in this format, so 1.15 is not a valid number. + # it must be refused, not silently read as 115 and rejected. + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1.15"}] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + + self.assertRaises(frappe.ValidationError, qa.save) + + dn.delete() + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###.##"}) + def test_reading_with_comma_in_dot_decimal_number_format(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + # the reported bug: 1,15 was read as 115 here and silently rejected + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + + self.assertRaises(frappe.ValidationError, qa.save) + + dn.delete() + def test_delete_quality_inspection_linked_with_stock_entry(self): item_code = create_item("_Test Cicuular Dependecy Item with QA").name From 5b5f354090c58fb6a0ff22cd06780d2c45e77010 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:12:21 +0530 Subject: [PATCH 4/5] fix(stock): accept every number a reading can be written as parse_float and is_valid_number each re-derived the number grammar, so the validator accepted strings flt() cannot parse: str.isdigit() lets superscripts through and lstrip("+-") lets repeated signs through, both then silently scored as 0. One parse_reading() returning None when float() refuses the value makes acceptance and conversion true by construction. The grammar was also wrong for several formats. Where the group separator is not a dot, a dot-decimal reading such as 1.15 parsed correctly before and is accepted again. #,### and #.### report no decimal separator at all, which rejected every fractional reading outright and, for #.###, reread a stored 1.500 as 1500.0; they now fall back to a dot and give up the grouping that would collide with it. Only readings that change are checked, so an inspection entered by a user in one locale stays saveable and submittable by a user in another, and manual inspection rows keep the free text they were never parsed for. NumberFormat replaces get_number_format_info, which frappe drops in v16. --- .../quality_inspection/quality_inspection.py | 93 +++++++++++-------- 1 file changed, 55 insertions(+), 38 deletions(-) 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)) From 00d17ca5db392adfe52ae149a7f3fd11bc1ab640 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:12:22 +0530 Subject: [PATCH 5/5] test(stock): cover reading number formats end to end Set the number format on the session user rather than on System Settings: the code reads the user default, which shadows the global one, so these tests never exercised the path they were written for. Restoring it in a finally also keeps a failed assertion from leaving the whole suite in another locale. Add a table test over every format in NUMBER_FORMAT_MAP, covering the grouped values and the three formats parse_float used to read as 0, and restore the formula-based coverage for non-numeric readings. --- .../test_quality_inspection.py | 202 +++++++++++++++--- 1 file changed, 171 insertions(+), 31 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 433004d1657..2ce6d4fe338 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -1,8 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt +from contextlib import contextmanager + import frappe from frappe.utils import nowdate +from frappe.utils.number_format import NumberFormat from erpnext.controllers.stock_controller import ( QualityInspectionNotSubmittedError, @@ -12,10 +15,29 @@ from erpnext.controllers.stock_controller import ( ) from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.quality_inspection.quality_inspection import ( + get_reading_separators, + parse_reading, +) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite +@contextmanager +def user_number_format(number_format): + """Temporarily set the session user's own number format.""" + user = frappe.session.user + previous = frappe.db.get_value("DefaultValue", {"parent": user, "defkey": "number_format"}, "defvalue") + frappe.defaults.set_user_default("number_format", number_format) + try: + yield + finally: + if previous: + frappe.defaults.set_user_default("number_format", previous) + else: + frappe.defaults.clear_user_default("number_format") + + class TestQualityInspection(ERPNextTestSuite): def setUp(self): super().setUp() @@ -255,7 +277,6 @@ class TestQualityInspection(ERPNextTestSuite): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) create_quality_inspection_parameter("Density") - # text in a numeric reading was read as 0, silently skewing the mean readings = [ {"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "random text"} ] @@ -267,74 +288,193 @@ class TestQualityInspection(ERPNextTestSuite): dn.delete() - @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#.###,##"}) + def test_non_numeric_reading_in_formula_based_criteria(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + readings = [ + { + "specification": "Density", + "formula_based_criteria": 1, + "acceptance_formula": "mean < 0.9", + "reading_1": "0.5", + "reading_2": "0.7", + "reading_3": "random text", + } + ] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + + self.assertRaises(frappe.ValidationError, qa.save) + + dn.delete() + + def test_manual_inspection_reading_is_not_number_checked(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + readings = [ + { + "specification": "Density", + "manual_inspection": 1, + "status": "Accepted", + "min_value": 1.15, + "max_value": 1.20, + "reading_1": "1.15 g/cm3", + } + ] + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() + + self.assertEqual(qa.readings[0].status, "Accepted") + + qa.delete() + dn.delete() + def test_reading_in_comma_decimal_number_format(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) create_quality_inspection_parameter("Density") readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] - qa = create_quality_inspection( - reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True - ) - qa.save() + with user_number_format("#.###,##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() - # 1,15 is 1.15 in this format, which is within the acceptance range - self.assertEqual(qa.readings[0].status, "Accepted") - self.assertEqual(qa.status, "Accepted") + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.status, "Accepted") qa.delete() dn.delete() - @ERPNextTestSuite.change_settings("System Settings", {"number_format": "# ###,##"}) def test_reading_in_space_grouped_number_format(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) create_quality_inspection_parameter("Density") - # this format is comma decimal but space grouped, so the separators were not - # swapped at all and 1,15 was read as 115 and rejected readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] - qa = create_quality_inspection( - reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True - ) - qa.save() + with user_number_format("# ###,##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() - self.assertEqual(qa.readings[0].status, "Accepted") - self.assertEqual(qa.status, "Accepted") + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.status, "Accepted") qa.delete() dn.delete() - @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#.###,##"}) def test_reading_in_wrong_decimal_number_format(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) create_quality_inspection_parameter("Density") - # a dot is the group separator in this format, so 1.15 is not a valid number. - # it must be refused, not silently read as 115 and rejected. readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1.15"}] - qa = create_quality_inspection( - reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True - ) + with user_number_format("#.###,##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) - self.assertRaises(frappe.ValidationError, qa.save) + self.assertRaises(frappe.ValidationError, qa.save) dn.delete() - @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###.##"}) def test_reading_with_comma_in_dot_decimal_number_format(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) create_quality_inspection_parameter("Density") - # the reported bug: 1,15 was read as 115 here and silently rejected readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] - qa = create_quality_inspection( - reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True - ) + with user_number_format("#,###.##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) - self.assertRaises(frappe.ValidationError, qa.save) + self.assertRaises(frappe.ValidationError, qa.save) dn.delete() + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###.##"}) + def test_reading_number_format_prefers_the_user_over_the_system(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] + with user_number_format("#.###,##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() + + self.assertEqual(qa.readings[0].status, "Accepted") + + qa.delete() + dn.delete() + + def test_stored_reading_stays_submittable_in_another_number_format(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + create_quality_inspection_parameter("Density") + + readings = [{"specification": "Density", "min_value": 1.15, "max_value": 1.20, "reading_1": "1,15"}] + with user_number_format("#.###,##"): + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, readings=readings, do_not_save=True + ) + qa.save() + + with user_number_format("#,###.##"): + qa.reload() + qa.submit() + + self.assertEqual(qa.docstatus, 1) + + qa.cancel() + qa.delete() + dn.delete() + + def test_parse_reading_in_every_number_format(self): + accepted = [ + ("#,###.##", "1.15", 1.15), + ("#,###.##", "1,234.56", 1234.56), + ("#,##,###.##", "12,34,567.89", 1234567.89), + ("#,###.###", "1,234.567", 1234.567), + ("#.###,##", "1,15", 1.15), + ("#.###,##", "1.234,56", 1234.56), + ("# ###,##", "1,15", 1.15), + ("# ###,##", "1.15", 1.15), + ("# ###,##", "1 234,56", 1234.56), + ("# ###.##", "1 234.56", 1234.56), + ("#'###.##", "1'234.56", 1234.56), + ("#, ###.##", "1, 234.56", 1234.56), + ("#.########", "1.15", 1.15), + ("#,###", "1.5", 1.5), + ("#,###", "1,500", 1500.0), + ("#.###", "1.5", 1.5), + ("#.###", "1.500", 1.5), + ("#,###.##", "-1,234.56", -1234.56), + ] + refused = [ + ("#,###.##", "1,15"), + ("#.###,##", "1.15"), + ("#,###.##", "--1.15"), + ("#,###.##", "1²"), + ("#,###.##", "nan"), + ("#,###.##", "random text"), + ("#,###", "1,50"), + ] + + for number_format, value, expected in accepted: + decimal_str, comma_str = get_reading_separators(NumberFormat.from_string(number_format)) + with self.subTest(number_format=number_format, value=value): + self.assertEqual(parse_reading(value, decimal_str, comma_str), expected) + + for number_format, value in refused: + decimal_str, comma_str = get_reading_separators(NumberFormat.from_string(number_format)) + with self.subTest(number_format=number_format, value=value): + self.assertIsNone(parse_reading(value, decimal_str, comma_str)) + def test_delete_quality_inspection_linked_with_stock_entry(self): item_code = create_item("_Test Cicuular Dependecy Item with QA").name