From 3b7fb6851a1d1d6eeb2a612fd303ca39b81a81ee Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:51:46 +0530 Subject: [PATCH 1/6] 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. (cherry picked from commit e74c0a3cdbc644f2113d4e64519d51950e0c698d) --- .../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 5e7a6ba307d..99d5d94f2da 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -82,6 +82,7 @@ class QualityInspection(Document): reading.status = "Accepted" if self.readings: + self.validate_reading_number_format() self.inspect_and_set_status() self.validate_inspection_required() @@ -280,6 +281,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 "" @@ -507,17 +539,56 @@ def make_quality_inspection(source_name, target_doc=None): 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 e2466780b93d6eb555db95a65b6c2a4cce274d9b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:52:03 +0530 Subject: [PATCH 2/6] 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. (cherry picked from commit 3752be809f72531df0fc2deb1a9e06d7221629a2) --- .../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 34b140515f2..0398d37cfd7 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 113b5ecaecadafb40e786fdff0e7fcb623864d6f Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 13 Jul 2026 16:52:13 +0530 Subject: [PATCH 3/6] 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. (cherry picked from commit b1f188146ea2554dc3fd85b5ef75f7fcaa834b17) --- .../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 0398d37cfd7..cec80fc8994 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 fccf1220f6d1946060871c8d45b6e12885b78266 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:12:21 +0530 Subject: [PATCH 4/6] 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. (cherry picked from commit 5b5f354090c58fb6a0ff22cd06780d2c45e77010) # Conflicts: # erpnext/stock/doctype/quality_inspection/quality_inspection.py --- .../quality_inspection/quality_inspection.py | 100 +++++++++++------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 99d5d94f2da..9491b72fe2d 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -2,11 +2,22 @@ # License: GNU General Public License v3. See license.txt +<<<<<<< HEAD +======= +from math import isfinite +from typing import Any + +>>>>>>> 5b5f354090 (fix(stock): accept every number a reading can be written as) import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +<<<<<<< HEAD from frappe.utils import cint, cstr, 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 +>>>>>>> 5b5f354090 (fix(stock): accept every number a reading can be written as) from erpnext.controllers.stock_controller import ( QI_INCOMING_PURPOSES, @@ -282,23 +293,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." @@ -306,7 +327,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"), @@ -539,42 +560,54 @@ def make_quality_inspection(source_name, target_doc=None): 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: @@ -582,13 +615,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 444dd9e817e1bdeaa6888935b6a6db595f92fdf3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:12:22 +0530 Subject: [PATCH 5/6] 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. (cherry picked from commit 00d17ca5db392adfe52ae149a7f3fd11bc1ab640) --- .../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 cec80fc8994..7284c71ab20 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 From 656db1c2fe3918811c1b038643a1c822c99f4b8f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:31:55 +0530 Subject: [PATCH 6/6] fix(stock): resolve backport conflict in quality inspection imports The backport left both import hunks unresolved, so the file did not compile. version-16-hotfix keeps item_query unannotated and still imports cstr, so only get_number_format_info goes, replaced by NumberFormat; typing.Any is not carried over because nothing on this branch uses it. --- .../doctype/quality_inspection/quality_inspection.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 9491b72fe2d..f213656fcd4 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -2,22 +2,14 @@ # License: GNU General Public License v3. See license.txt -<<<<<<< HEAD -======= from math import isfinite -from typing import Any ->>>>>>> 5b5f354090 (fix(stock): accept every number a reading can be written as) import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -<<<<<<< HEAD -from frappe.utils import cint, cstr, flt, get_link_to_form, get_number_format_info -======= -from frappe.utils import cint, flt, get_link_to_form +from frappe.utils import cint, cstr, flt, get_link_to_form from frappe.utils.number_format import NUMBER_FORMAT_MAP, NumberFormat ->>>>>>> 5b5f354090 (fix(stock): accept every number a reading can be written as) from erpnext.controllers.stock_controller import ( QI_INCOMING_PURPOSES,