mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user