Remove Stellenanteil matching, use name and Funktion hint only
This commit is contained in:
1 parent
56f6e3b292
commit
bd2c0d512a
1 file changed
+36
-59
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user