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.
This commit is contained in:
Mihir Kandoi
2026-08-03 12:12:21 +05:30
parent b1f188146e
commit 5b5f354090

View File

@@ -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))