fix(salary): parse localized numeric strings (#272)

This commit is contained in:
Ayobami Adegoke
2026-08-02 21:15:09 +02:00
committed by GitHub
parent bdf6d0ac45
commit 4f7f11ef4e
2 changed files with 62 additions and 3 deletions
+40
View File
@@ -189,6 +189,46 @@ class DetectColumnTypeTests(unittest.TestCase):
self.assertIn("salary_index", companies[0]["categories"]) self.assertIn("salary_index", companies[0]["categories"])
self.assertEqual(companies[0]["categories"]["salary_index"], {"index": 105.5}) self.assertEqual(companies[0]["categories"]["salary_index"], {"index": 105.5})
def test_parse_sheet_accepts_comma_decimal_string_values(self):
# Locale-formatted Excel exports can carry numeric cells as strings.
# Danish decimal commas must not be silently dropped by float().
ws = FakeWorksheet([
("Company", "Engineering Count", "Engineering Index"),
("Example Corp", "12,0", "108,5"),
])
companies = parse_sheet(ws)
self.assertEqual(
companies[0]["categories"]["engineering"],
{"count": 12, "index": 108.5},
)
def test_parse_sheet_accepts_danish_thousands_and_decimal_string(self):
ws = FakeWorksheet([
("Company", "Salary Index"),
("Example Corp", "1.234,5"),
])
companies = parse_sheet(ws)
self.assertEqual(
companies[0]["categories"]["salary_index"],
{"index": 1234.5},
)
def test_parse_sheet_skips_ambiguous_single_comma_thousands_string(self):
# In an English-locale export, "1,234" is probably 1234, but in a
# decimal-comma locale it could be 1.234. Preserve the old safe-skip
# behavior instead of guessing and writing a 1000x-wrong salary value.
ws = FakeWorksheet([
("Company", "Salary Index"),
("Example Corp", "1,234"),
])
companies = parse_sheet(ws)
self.assertEqual(companies[0]["categories"], {})
def test_parse_sheet_pairs_interleaved_count_index_columns_by_name(self): def test_parse_sheet_pairs_interleaved_count_index_columns_by_name(self):
ws = FakeWorksheet([ ws = FakeWorksheet([
+22 -3
View File
@@ -54,6 +54,25 @@ COMPOUND_PATTERNS = {"antal", "indeks", "løn", "gennemsnit", "medarbejdere"}
ID_PATTERNS = {"id", "personnummer"} ID_PATTERNS = {"id", "personnummer"}
def parse_numeric_cell(value):
"""Parse numeric Excel values, including localized string cells."""
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
raise ValueError("not numeric")
text = value.strip().replace("\u00a0", " ").replace(" ", "")
if not text:
raise ValueError("not numeric")
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
elif "," in text:
if re.fullmatch(r"[+-]?\d+,\d{3}", text):
raise ValueError("ambiguous comma separator")
text = text.replace(",", ".")
return float(text)
def header_matches(header, patterns): def header_matches(header, patterns):
"""Return True when a header contains a meaningful pattern match. """Return True when a header contains a meaningful pattern match.
@@ -211,12 +230,12 @@ def parse_sheet(ws, sheet_label=None):
index_val = None index_val = None
if cat["count_col"] < len(row) and row[cat["count_col"]] is not None: if cat["count_col"] < len(row) and row[cat["count_col"]] is not None:
try: try:
count_val = int(row[cat["count_col"]]) count_val = int(parse_numeric_cell(row[cat["count_col"]]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if cat["index_col"] < len(row) and row[cat["index_col"]] is not None: if cat["index_col"] < len(row) and row[cat["index_col"]] is not None:
try: try:
index_val = float(row[cat["index_col"]]) index_val = parse_numeric_cell(row[cat["index_col"]])
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# A count/index pair that is entirely empty for this row carries # A count/index pair that is entirely empty for this row carries
@@ -228,7 +247,7 @@ def parse_sheet(ws, sheet_label=None):
if cat["value_col"] < len(row) and row[cat["value_col"]] is not None: if cat["value_col"] < len(row) and row[cat["value_col"]] is not None:
val = row[cat["value_col"]] val = row[cat["value_col"]]
try: try:
val = float(val) val = parse_numeric_cell(val)
except (ValueError, TypeError): except (ValueError, TypeError):
# Non-numeric standalone value (e.g. a free-text "Notes" # Non-numeric standalone value (e.g. a free-text "Notes"
# column) is not salary data; skip it for this row. # column) is not salary data; skip it for this row.