Remove Stellenanteil matching, use name and Funktion hint only

This commit is contained in:
Rob committed 2026-04-21 16:32:27 +02:00
1 parent 56f6e3b292
commit bd2c0d512a
1 file changed
+36 -59
+36 -59
View File
@@ -1,4 +1,4 @@
"""Record matching logic across allocation and master data.""" """Record matching logic across allocation and master data."""
from __future__ import annotations from __future__ import annotations
@@ -15,7 +15,6 @@ def calculate_foerderquote_percent(allocation: AllocationRecord, precision: int
raise ValidationError( raise ValidationError(
"contract_hours_weekly must be greater than 0 for foerderquote calculation." "contract_hours_weekly must be greater than 0 for foerderquote calculation."
) )
raw_share = (allocation.funded_hours_weekly / allocation.contract_hours_weekly) * 100.0 raw_share = (allocation.funded_hours_weekly / allocation.contract_hours_weekly) * 100.0
return round(raw_share, precision) return round(raw_share, precision)
@@ -31,7 +30,6 @@ def _normalize_name(name: str) -> str:
def _name_keys(name: str) -> set[str]: def _name_keys(name: str) -> set[str]:
normalized = _normalize_name(name) normalized = _normalize_name(name)
keys = {normalized} keys = {normalized}
if "," in normalized: if "," in normalized:
last_name, first_name = [part.strip() for part in normalized.split(",", 1)] last_name, first_name = [part.strip() for part in normalized.split(",", 1)]
if first_name and last_name: if first_name and last_name:
@@ -44,16 +42,17 @@ def _name_keys(name: str) -> set[str]:
last_name = tokens[-1] last_name = tokens[-1]
keys.add(f"{first_name} {last_name}") keys.add(f"{first_name} {last_name}")
keys.add(f"{last_name}, {first_name}") keys.add(f"{last_name}, {first_name}")
return {re.sub(r"\s+", " ", key).strip() for key in keys if key.strip()} return {re.sub(r"\s+", " ", key).strip() for key in keys if key.strip()}
def _with_tolerance( def _disambiguate_by_project_key(
value: float, candidates: list,
target: float, project_key: str,
tolerance: float, ) -> object:
) -> bool: """Return the single candidate whose Funktion contains the project key, or None."""
return abs(value - target) <= tolerance 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( def match_assignments(
@@ -61,25 +60,33 @@ def match_assignments(
masterdata_records: list[MasterDataRecord], masterdata_records: list[MasterDataRecord],
config: dict[str, Any], config: dict[str, Any],
) -> tuple[list[MatchedAssignment], list[ValidationIssue]]: ) -> tuple[list[MatchedAssignment], list[ValidationIssue]]:
matching_cfg = config.get("matching", {}) # Build lookup by all normalized name variants
tolerance = float(matching_cfg.get("share_tolerance_percent", 0.5))
prefer_function_hint = bool(matching_cfg.get("prefer_function_hint", True))
master_by_name: dict[str, list[MasterDataRecord]] = {} master_by_name: dict[str, list[MasterDataRecord]] = {}
for record in masterdata_records: for record in masterdata_records:
for key in _name_keys(record.name): for key in _name_keys(record.name):
master_by_name.setdefault(key, []).append(record) 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] = [] matched: list[MatchedAssignment] = []
issues: list[ValidationIssue] = [] issues: list[ValidationIssue] = []
for allocation in allocation_records: for allocation in allocation_records:
# Collect all master-data candidates for this employee name
candidate_map: dict[str, MasterDataRecord] = {} candidate_map: dict[str, MasterDataRecord] = {}
for key in _name_keys(allocation.employee_name): for key in _name_keys(allocation.employee_name):
for candidate in master_by_name.get(key, []): for candidate in master_by_name.get(key, []):
candidate_map[candidate.record_id] = candidate candidate_map[candidate.record_id] = candidate
candidates = list(candidate_map.values()) candidates = list(candidate_map.values())
if not candidates: if not candidates:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
@@ -94,66 +101,36 @@ def match_assignments(
) )
continue continue
foerderquote = calculate_foerderquote_percent(allocation) # Single candidate: use directly
share_matches = [ if len(candidates) == 1:
candidate selected = candidates[0]
for candidate in candidates else:
if _with_tolerance(candidate.stellenanteil_percent, foerderquote, tolerance) # Multiple candidates: disambiguate by project key in Funktion
] selected = _disambiguate_by_project_key(candidates, allocation.project_key)
if selected is None:
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:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
code="AMBIGUOUS_MASTERDATA", code="AMBIGUOUS_MASTERDATA",
message=( message=(
f"Ambiguous match for employee '{allocation.employee_name}' " f"Ambiguous master-data for '{allocation.employee_name}' / "
f"(project '{allocation.project_key}', foerderquote {foerderquote:.2f}%)." f"'{allocation.project_key}': {len(candidates)} rows, "
f"none or multiple match Funktion. "
f"Funktionen: {[c.funktion for c in candidates]}"
), ),
severity="error", severity="error",
context=allocation.employee_name, context=allocation.employee_name,
) )
) )
continue continue
elif len(share_matches) > 1:
issues.append( foerderquote = calculate_foerderquote_percent(allocation)
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
if allocation.notes: if allocation.notes:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
code="ALLOCATION_NOTE", code="ALLOCATION_NOTE",
message=( message=(
f"Allocation note for '{allocation.employee_name}' / " f"Note for '{allocation.employee_name}' / "
f"'{allocation.project_key}': {allocation.notes}" f"'{allocation.project_key}': {allocation.notes}"
), ),
severity="warning", severity="warning",