diff --git a/src/taetigkeitsnachweis/matcher.py b/src/taetigkeitsnachweis/matcher.py index 9a3d82a..a73b77c 100644 --- a/src/taetigkeitsnachweis/matcher.py +++ b/src/taetigkeitsnachweis/matcher.py @@ -1,4 +1,4 @@ -"""Record matching logic across allocation and master data.""" +"""Record matching logic across allocation and master data.""" from __future__ import annotations @@ -15,7 +15,6 @@ def calculate_foerderquote_percent(allocation: AllocationRecord, precision: int raise ValidationError( "contract_hours_weekly must be greater than 0 for foerderquote calculation." ) - raw_share = (allocation.funded_hours_weekly / allocation.contract_hours_weekly) * 100.0 return round(raw_share, precision) @@ -31,7 +30,6 @@ def _normalize_name(name: str) -> str: def _name_keys(name: str) -> set[str]: normalized = _normalize_name(name) keys = {normalized} - if "," in normalized: last_name, first_name = [part.strip() for part in normalized.split(",", 1)] if first_name and last_name: @@ -44,16 +42,17 @@ def _name_keys(name: str) -> set[str]: last_name = tokens[-1] keys.add(f"{first_name} {last_name}") keys.add(f"{last_name}, {first_name}") - return {re.sub(r"\s+", " ", key).strip() for key in keys if key.strip()} -def _with_tolerance( - value: float, - target: float, - tolerance: float, -) -> bool: - return abs(value - target) <= tolerance +def _disambiguate_by_project_key( + candidates: list, + project_key: str, +) -> object: + """Return the single candidate whose Funktion contains the project key, or None.""" + key = project_key.strip().casefold() + matched = [c for c in candidates if key in c.funktion.casefold()] + return matched[0] if len(matched) == 1 else None def match_assignments( @@ -61,25 +60,33 @@ def match_assignments( masterdata_records: list[MasterDataRecord], config: dict[str, Any], ) -> tuple[list[MatchedAssignment], list[ValidationIssue]]: - matching_cfg = config.get("matching", {}) - tolerance = float(matching_cfg.get("share_tolerance_percent", 0.5)) - prefer_function_hint = bool(matching_cfg.get("prefer_function_hint", True)) - + # Build lookup by all normalized name variants master_by_name: dict[str, list[MasterDataRecord]] = {} for record in masterdata_records: for key in _name_keys(record.name): master_by_name.setdefault(key, []).append(record) + # Deduplicate so the same record does not appear twice for one name key + for key in master_by_name: + seen_ids: set[str] = set() + deduped: list[MasterDataRecord] = [] + for rec in master_by_name[key]: + if rec.record_id not in seen_ids: + seen_ids.add(rec.record_id) + deduped.append(rec) + master_by_name[key] = deduped + matched: list[MatchedAssignment] = [] issues: list[ValidationIssue] = [] for allocation in allocation_records: + # Collect all master-data candidates for this employee name candidate_map: dict[str, MasterDataRecord] = {} for key in _name_keys(allocation.employee_name): for candidate in master_by_name.get(key, []): candidate_map[candidate.record_id] = candidate - candidates = list(candidate_map.values()) + if not candidates: issues.append( ValidationIssue( @@ -94,66 +101,36 @@ def match_assignments( ) continue - foerderquote = calculate_foerderquote_percent(allocation) - share_matches = [ - candidate - for candidate in candidates - if _with_tolerance(candidate.stellenanteil_percent, foerderquote, tolerance) - ] - - if len(share_matches) == 1: - selected = share_matches[0] - elif len(share_matches) > 1 and prefer_function_hint: - key = allocation.project_key.casefold() - hinted = [c for c in share_matches if key in c.funktion.casefold()] - if len(hinted) == 1: - selected = hinted[0] - else: + # Single candidate: use directly + if len(candidates) == 1: + selected = candidates[0] + else: + # Multiple candidates: disambiguate by project key in Funktion + selected = _disambiguate_by_project_key(candidates, allocation.project_key) + if selected is None: issues.append( ValidationIssue( code="AMBIGUOUS_MASTERDATA", message=( - f"Ambiguous match for employee '{allocation.employee_name}' " - f"(project '{allocation.project_key}', foerderquote {foerderquote:.2f}%)." + f"Ambiguous master-data for '{allocation.employee_name}' / " + f"'{allocation.project_key}': {len(candidates)} rows, " + f"none or multiple match Funktion. " + f"Funktionen: {[c.funktion for c in candidates]}" ), severity="error", context=allocation.employee_name, ) ) continue - elif len(share_matches) > 1: - issues.append( - ValidationIssue( - code="AMBIGUOUS_MASTERDATA", - message=( - f"Ambiguous match for employee '{allocation.employee_name}' " - f"(project '{allocation.project_key}', foerderquote {foerderquote:.2f}%)." - ), - severity="error", - context=allocation.employee_name, - ) - ) - continue - else: - issues.append( - ValidationIssue( - code="NO_SHARE_MATCH", - message=( - f"No Stellenanteil match for employee '{allocation.employee_name}' " - f"with foerderquote {foerderquote:.2f}% (project '{allocation.project_key}')." - ), - severity="error", - context=allocation.employee_name, - ) - ) - continue + + foerderquote = calculate_foerderquote_percent(allocation) if allocation.notes: issues.append( ValidationIssue( code="ALLOCATION_NOTE", message=( - f"Allocation note for '{allocation.employee_name}' / " + f"Note for '{allocation.employee_name}' / " f"'{allocation.project_key}': {allocation.notes}" ), severity="warning",