diff --git a/brancheneinstufung.py b/brancheneinstufung.py index b7429bbb..c86de3e7 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -717,107 +717,147 @@ def fuzzy_similarity(str1, str2): # Globale Funktion (ersetzen Sie Ihre bestehende Version vollständig hiermit) def extract_numeric_value(raw_value, is_umsatz=False): logger = logging.getLogger(__name__ + ".extract_numeric_value") - if raw_value is None or pd.isna(raw_value): return "k.A." - - text_original_for_units = str(raw_value).strip().lower() # Für Einheiten-Keywords + if raw_value is None or pd.isna(raw_value): + return "k.A." + + raw_value_str_original_for_debug = str(raw_value) text_to_parse = str(raw_value).strip() - if not text_to_parse or text_to_parse.lower() in ['k.a.', 'n/a', '-']: return "k.A." - if text_to_parse in ['0', '0.0', '0,00', '0.000', '0.00']: return "0" # Explizite 0 bleibt "0" + if not text_to_parse or text_to_parse.lower() in ['k.a.', 'n/a', '-']: + return "k.A." + + # Vor dem Parsen prüfen, ob der Input-String selbst "0" oder eine Variation davon ist. + # Wenn ja, "0" zurückgeben. Die Logik in _get_numeric_value_for_plausi + # wird dies dann korrekt als "unbekannt" (np.nan) interpretieren. + # Leerzeichen werden hier noch nicht entfernt, um "0 . 0" nicht fälschlich als "0" zu erkennen. + test_val_for_zero = text_to_parse.replace(',', '.') + if test_val_for_zero in ['0', '0.0', '0.00', '0.000']: + return "0" try: # Schritt 1: Grobe Vorreinigung - text_to_parse = clean_text(text_to_parse) - if text_to_parse.lower() in ['k.a.', 'n/a', '-']: return "k.A." + # text_cleaned_for_units wird für die reine Einheitensuche verwendet, daher früh cleanen. + text_cleaned_for_units = clean_text(text_to_parse).lower() + + text_processed = text_to_parse # Arbeitskopie für Zahlenextraktion + text_processed = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', text_processed) + text_processed = re.sub(r'\(.*?\)|\[.*?\]', '', text_processed) # Klammern und eckige Klammern entfernen + text_processed = re.sub(r'[€$£¥]\s*|[Cc][Hh][Ff]\s*', '', text_processed, flags=re.IGNORECASE).strip() + text_processed = re.split(r'\s*(-|–|bis)\s*', text_processed, 1)[0].strip() + + if not text_processed: + logger.debug(f"Text nach Vorreinigung leer für '{raw_value_str_original_for_debug}'") + return "k.A." - text_to_parse = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|unter|mehr als|weniger als|bis zu)\s+', '', text_to_parse) - text_to_parse = re.sub(r'\(.*?\)', '', text_to_parse) # Klammern früh entfernen - text_to_parse = re.sub(r'[€$£¥CHF]', '', text_to_parse, flags=re.IGNORECASE).strip() - text_to_parse = re.split(r'\s*(-|–|bis)\s*', text_to_parse, 1)[0].strip() - - if not text_to_parse: return "k.A." - - # Schritt 2: Versuche, die Zahl und eine eventuelle Einheit zu trennen - # Regex, um eine Zahl am Anfang des Strings zu finden, optional gefolgt von Text - # Erlaubt Punkte, Kommas, Apostrophe und Leerzeichen in der Zahl - # Diese Regex ist sehr gierig für den Zahlenteil. - num_match = re.match(r'([\d.,\'\s]+)', text_to_parse) + # Schritt 2: Zahl und Einheit trennen. + match = re.match(r'([\d.,\'\s]+)(.*)', text_processed) num_str_candidate = "" unit_part_str = "" - if num_match: - num_str_candidate = num_match.group(1).strip() - # Der Rest des Strings nach der Zahl könnte die Einheit sein - unit_part_str = text_to_parse[len(num_match.group(0)):].strip().lower() - else: # Kein Zahlenmatch am Anfang - logger.debug(f"Kein initialer Zahlen-Match in '{text_to_parse}' (Original: '{raw_value_str_original}')") - return "k.A." + if match: + num_str_candidate = match.group(1).strip() + unit_part_str = match.group(2).strip() + else: + match_num_only = re.match(r'([\d.,\'\s]+)', text_processed) + if match_num_only: + num_str_candidate = match_num_only.group(1).strip() + else: + logger.debug(f"Kein initialer Zahlen-Match in '{text_processed}' (Original: '{raw_value_str_original_for_debug}')") + return "k.A." + + if not num_str_candidate: # Falls num_str_candidate leer ist nach strip() + logger.debug(f"Zahlenkandidat war leer oder nur Whitespace für '{raw_value_str_original_for_debug}'") + return "k.A." # Schritt 3: Bereinige den extrahierten Zahlen-String - cleaned_num_str = num_str_candidate.replace("'", "").replace(" ", "") # Apostrophe und alle Leerzeichen - + cleaned_num_str = num_str_candidate.replace("'", "").replace(" ", "") + if not cleaned_num_str: + logger.debug(f"Zahlenkandidat '{num_str_candidate}' wurde zu leerem String nach Entfernung von ' und Leerraum.") + return "k.A." + has_dot = '.' in cleaned_num_str has_comma = ',' in cleaned_num_str if has_dot and has_comma: - if cleaned_num_str.rfind('.') > cleaned_num_str.rfind(','): # US: 1,234.56 + last_dot_pos = cleaned_num_str.rfind('.') + last_comma_pos = cleaned_num_str.rfind(',') + if last_dot_pos > last_comma_pos: cleaned_num_str = cleaned_num_str.replace(',', '') - else: # EU: 1.234,56 - cleaned_num_str = cleaned_num_str.replace('.', '').replace(',', '.') - elif has_comma: # Nur Kommas - if cleaned_num_str.count(',') == 1 and re.search(r',\d{1,2}$', cleaned_num_str) and not re.search(r',\d{3}', cleaned_num_str): + else: + cleaned_num_str = cleaned_num_str.replace('.', '') cleaned_num_str = cleaned_num_str.replace(',', '.') - else: + elif has_comma: + # Fall: "1,234,567" -> 1234567 + # Fall: "1,234" -> 1234 + # Fall: "1,23" -> 1.23 + # Fall: "1," -> k.A. (wird später abgefangen) + # Nur das *letzte* Komma kann ein Dezimaltrennzeichen sein, wenn es von 1 oder 2 Ziffern gefolgt wird. + if re.search(r',\d{1,2}$', cleaned_num_str): + # Das letzte Komma ist wahrscheinlich ein Dezimaltrennzeichen. + # Ersetze nur dieses letzte Komma durch einen Punkt. + # Alle anderen Kommas (falls vorhanden) sind Tausendertrenner. + parts = cleaned_num_str.rsplit(',', 1) + integer_part = parts[0].replace(',', '') + cleaned_num_str = f"{integer_part}.{parts[1]}" + else: # Alle Kommas sind Tausendertrenner cleaned_num_str = cleaned_num_str.replace(',', '') - elif has_dot: # Nur Punkte - if cleaned_num_str.count('.') == 1 and re.search(r'\.\d{1,2}$', cleaned_num_str) and not re.search(r'\.\d{3}', cleaned_num_str): - pass - else: + elif has_dot: + # Analog zu Kommas + if re.search(r'\.\d{1,2}$', cleaned_num_str): + parts = cleaned_num_str.rsplit('.', 1) + integer_part = parts[0].replace('.', '') + cleaned_num_str = f"{integer_part}.{parts[1]}" + else: cleaned_num_str = cleaned_num_str.replace('.', '') if not re.fullmatch(r'-?\d+(\.\d+)?', cleaned_num_str): - logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{cleaned_num_str}' (Num-Kandidat: '{num_str_candidate}', Original: '{raw_value_str_original}')") + logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{cleaned_num_str}' (Num-Kandidat: '{num_str_candidate}', Original: '{raw_value_str_original_for_debug}')") return "k.A." num_as_float = float(cleaned_num_str) - # Schritt 4: Einheiten-Skalierung basierend auf unit_part_str oder text_original_for_units + # Schritt 4: Einheiten-Skalierung scaled_num = num_as_float - - # Prüfe zuerst den direkt nach der Zahl extrahierten unit_part_str - # Dann als Fallback den gesamten originalen String (text_original_for_units) - source_for_unit_check = unit_part_str if unit_part_str else text_original_for_units + string_for_unit_search = unit_part_str.lower() if unit_part_str else text_cleaned_for_units if is_umsatz: - if re.search(r'^mrd\.?|^milliarden|^billion', source_for_unit_check): - scaled_num = num_as_float * 1000.0 - elif re.search(r'^tsd\.?|^tausend|^k\b', source_for_unit_check): - scaled_num = num_as_float / 1000.0 - # Wenn `source_for_unit_check` mit "mio" beginnt, ist num_as_float schon Mio. - # Wenn `source_for_unit_check` leer ist (also nur eine Zahl da war), wird num_as_float als Mio. interpretiert. - else: # Mitarbeiter - if re.search(r'^mrd\.?|^milliarden|^billion', source_for_unit_check): scaled_num = num_as_float * 1000000000.0 - elif re.search(r'^mio\.?|^millionen|^mill\.?', source_for_unit_check): scaled_num = num_as_float * 1000000.0 - elif re.search(r'^tsd\.?|^tausend|^k\b', source_for_unit_check): scaled_num = num_as_float * 1000.0 - - if pd.isna(scaled_num): return "k.A." # Sollte nicht passieren, wenn num_as_float eine Zahl war - - if scaled_num == 0 and raw_value_str_original.strip() in ['0', '0.0', '0,00', '0.000', '0.00']: - return "0" - elif scaled_num >= 0: # Auch eine berechnete 0 (z.B. aus 0 Tsd) wird als "0" String zurückgegeben - return str(int(round(scaled_num))) + multiplikator = 1.0 + if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search): + multiplikator = 1000.0 + elif re.search(r'\b(mio\.?|mill\.?|millionen)\b', string_for_unit_search): + multiplikator = 1.0 + elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search): + multiplikator = 0.001 + scaled_num = num_as_float * multiplikator else: + multiplikator = 1.0 + if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search): + multiplikator = 1000000000.0 + elif re.search(r'\b(mio\.?|mill\.?|millionen)\b', string_for_unit_search): + multiplikator = 1000000.0 + elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search): + multiplikator = 1000.0 + scaled_num = num_as_float * multiplikator + + if pd.isna(scaled_num): return "k.A." - except ValueError as e: - logger.debug(f"ValueError '{e}' bei Konvertierung (extract_numeric_value) von '{cleaned_num_str if 'cleaned_num_str' in locals() else raw_value_str_original[:30]}...'") + if scaled_num >= 0: + return str(int(round(scaled_num))) + else: + logger.debug(f"Negative Zahl nach Skalierung: {scaled_num} für Input '{raw_value_str_original_for_debug}'") + return "k.A." + + except ValueError as e: + logger.debug(f"ValueError bei Konvertierung zu float: '{e}' (cleaned_num_str: '{cleaned_num_str if 'cleaned_num_str' in locals() else 'N/A'}', Original: '{raw_value_str_original_for_debug[:30]}...')") return "k.A." except Exception as e_general: - logger.error(f"Unerwarteter Fehler in extract_numeric_value für '{raw_value_str_original[:50]}...': {e_general}") + logger.error(f"Unerwarteter Fehler in extract_numeric_value für '{raw_value_str_original_for_debug[:50]}...': {e_general}") logger.debug(traceback.format_exc()) return "k.A." + # --- Numerische Extraktion fuer FILTERLOGIK (gibt 0 statt k.A. zurueck) --- # Basierend auf Code aus Teil 2. # Extrahiert und normalisiert Zahlenwerte fuer Vergleichslogik.