10 Commits
9 changed files with 621 additions and 121 deletions

No files matched your search

+31 -24
View File
@@ -59,37 +59,44 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon
- [x] Monatsmarker parsebar - [x] Monatsmarker parsebar
- [x] Gewuenschter Monat exakt einmal vorhanden - [x] Gewuenschter Monat exakt einmal vorhanden
- [x] Stunden-/Vertragswerte gueltig - [x] Stunden-/Vertragswerte gueltig
- [ ] T10: Business-Validierung implementieren - [x] T10: Business-Validierung implementieren
- [ ] Mindestens ein aktiver Fall vorhanden - [x] Mindestens ein aktiver Fall vorhanden
- [ ] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler - [x] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler
- [ ] Template enthaelt genau ein Quellblatt - [x] Template enthaelt genau ein Quellblatt
- [ ] Dateinamenkollisionen vermeiden - [x] Dateinamenkollisionen vermeiden
- [ ] Pflicht-Zielzellen vollstaendig konfiguriert - [x] Pflicht-Zielzellen vollstaendig konfiguriert
## Phase 6 - Ausgabeerzeugung ## Phase 6 - Ausgabeerzeugung
- [ ] T11: Template-Verarbeitung bauen - [x] T11: Template-Verarbeitung bauen
- [ ] Template-Workbook laden - [x] Template-Workbook laden
- [ ] Sheet pro aktiver Zuordnung klonen - [x] Sheet pro aktiver Zuordnung klonen
- [ ] Inhalte assignment-spezifisch in Zielzellen schreiben - [x] Inhalte assignment-spezifisch in Zielzellen schreiben
- [ ] T12: Dateinamen und Speichern - [x] T12: Dateinamen und Speichern
- [ ] Muster: `Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx` - [x] Muster: `Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx`
- [ ] Pro Mitarbeiter genau eine Datei pro Monat erzeugen - [x] Pro Mitarbeiter genau eine Datei pro Monat erzeugen
- [ ] Bei mehreren Zuordnungen: mehrere Sheets in derselben Datei - [x] Bei mehreren Zuordnungen: mehrere Sheets in derselben Datei
## Phase 7 - CLI und Benutzerfluss ## Phase 7 - CLI und Benutzerfluss
- [ ] T13: CLI in `generate.py` implementieren - [x] T13: CLI in `generate.py` implementieren
- [ ] Parameter `--month` und `--year` - [x] Parameter `--month` und `--year`
- [ ] Pipeline orchestrieren: parse -> match -> validate -> preview -> write - [x] Pipeline orchestrieren: parse -> match -> validate -> preview -> write
- [ ] Kontrollierter Exit-Code bei Fehlern - [x] Kontrollierter Exit-Code bei Fehlern
- [ ] T14: Vorschau + Bestaetigung vor Schreiben - [x] T14: Vorschau + Bestaetigung vor Schreiben
- [ ] Uebersicht zu Berichtsmonat, aktiven Faellen, Fehlern - [x] Uebersicht zu Berichtsmonat, aktiven Faellen, Fehlern
- [ ] Liste der geplanten Ausgabedateien - [x] Liste der geplanten Ausgabedateien
- [ ] Interaktive Bestaetigung (Abbruch ohne Schreibzugriff moeglich) - [x] Interaktive Bestaetigung (Abbruch ohne Schreibzugriff moeglich)
## Phase 8 - Tests und Abnahme ## Phase 8 - Tests und Abnahme
- [ ] T15: Smoke-Test fuer End-to-End-Lauf - [ ] T15: Smoke-Test fuer End-to-End-Lauf
- [ ] Beispielmonat ausfuehren - [x] Beispielmonat ausfuehren
- [x] Pipeline laeuft bis Dateiausgabe durch (0 Dateien, da 0 gematchte Faelle)
- [ ] Dateien in `output/` entstehen erwartungsgemaess - [ ] Dateien in `output/` entstehen erwartungsgemaess
- [ ] DATENBLOCKER: `Stellenbeschreibungen.xlsx` enthaelt nur Meyer,Johannes (25%/75%).
Alle anderen aktiven Mitarbeiter aus PS_PW.xlsx fehlen als Stammdaten.
Meyer's Foerderquote (85%/15%) stimmt nicht mit eingetragenem Stellenanteil (25%/75%) ueberein.
-> Stellenbeschreibungen.xlsx mit allen aktiven Mitarbeitern und korrekten Anteilen befuellen.
- [x] Template-Zielzellen konfiguriert (A3, A17, Q17, A21, Q21, A25, A31, I95)
- [x] Template-Sheet-Pfad konfiguriert (<MM_YYYY>_<Stellenbeschreibung>)
- [ ] T16: Fachtests definieren und ausfuehren - [ ] T16: Fachtests definieren und ausfuehren
- [ ] Mehrfachzuordnung eines Mitarbeiters - [ ] Mehrfachzuordnung eines Mitarbeiters
- [ ] Fehlender Match - [ ] Fehlender Match
@@ -111,4 +118,4 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon
8. T15 -> T16 -> T17 8. T15 -> T16 -> T17
## Aktueller Startpunkt ## Aktueller Startpunkt
- [ ] Naechste aktive Aufgabe: T10 (Business-Validierung implementieren) - [ ] Naechste aktive Aufgabe: T15 (Smoke-Test fuer End-to-End-Lauf)
+1
View File
@@ -0,0 +1 @@
__pycache__
+13 -12
View File
@@ -12,7 +12,7 @@
"sheets": { "sheets": {
"allocation": "Tabelle1", "allocation": "Tabelle1",
"masterdata": "Tabelle1", "masterdata": "Tabelle1",
"template_source": "Tabelle1" "template_source": "<MM_YYYY>_<Stellenbeschreibung>"
}, },
"allocation": { "allocation": {
"header_month_marker_row": 2, "header_month_marker_row": 2,
@@ -41,9 +41,10 @@
"antragsnummer": "B", "antragsnummer": "B",
"name": "C", "name": "C",
"qualifikation": "D", "qualifikation": "D",
"stellenanteil": "E", "project_scope": "E",
"funktion": "F", "stellenanteil": "F",
"taetigkeit": "G" "funktion": "G",
"taetigkeit": "H"
}, },
"decimal": { "decimal": {
"decimal_separator": ",", "decimal_separator": ",",
@@ -66,14 +67,14 @@
"sheet_name_pattern": "{index}_{funktion}" "sheet_name_pattern": "{index}_{funktion}"
}, },
"template_targets": { "template_targets": {
"antragsnummer": "", "antragsnummer": "A3",
"name_vorname": "", "name_vorname": "A17",
"qualifikation": "", "qualifikation": "A21",
"funktion": "", "funktion": "A25",
"taetigkeiten": "", "taetigkeiten": "A31",
"reporting_month_label": "", "reporting_month_label": "Q21",
"foerderquote": "", "foerderquote": "Q17",
"generated_on": "" "generated_on": "I95"
}, },
"runtime": { "runtime": {
"abort_on_validation_errors": true, "abort_on_validation_errors": true,
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import json
from pathlib import Path
import click
from src.taetigkeitsnachweis.errors import TaetigkeitsnachweisError
from src.taetigkeitsnachweis.logging_utils import configure_logging
from src.taetigkeitsnachweis.matcher import match_assignments
from src.taetigkeitsnachweis.parser import parse_allocation_records, parse_masterdata_records
from src.taetigkeitsnachweis.validator import validate_business_rules, validate_sources
from src.taetigkeitsnachweis.writer import (
build_employee_workbooks,
build_output_filename,
save_employee_workbooks,
)
def _load_config(project_root: Path) -> dict:
config_path = project_root / "config.json"
with config_path.open("r", encoding="utf-8") as config_file:
return json.load(config_file)
def _print_issues(issues: list, title: str) -> None:
if not issues:
return
click.echo(title)
for issue in issues:
click.echo(f"- [{issue.severity}] {issue.code}: {issue.message}")
def _print_preview(month: int, year: int, matched_assignments: list, planned_files: list[str]) -> None:
click.echo("=== Vorschau der ermittelten Werte ===")
click.echo(f"Berichtsmonat : {month:02d}/{year}")
click.echo(f"Aktive Faelle : {len(matched_assignments)}")
click.echo("--- Zu erzeugende Taetigkeitsnachweise ---")
for item in matched_assignments:
click.echo(
"- "
f"{item.allocation.employee_name} | "
f"{item.masterdata.funktion} | "
f"{item.foerderquote_percent:.2f}%"
)
click.echo("--- Geplante Ausgabedateien ---")
for file_name in planned_files:
click.echo(f"- {file_name}")
@click.command()
@click.option("--month", type=click.IntRange(1, 12), required=True)
@click.option("--year", type=click.IntRange(2000, 2100), required=True)
@click.option("--yes", is_flag=True, default=False, help="Skip interactive confirmation.")
@click.option("--skip-errors", is_flag=True, default=False, help="Generate files for matched records, skip unresolved ones.")
def main(month: int, year: int, yes: bool, skip_errors: bool) -> None:
configure_logging()
project_root = Path(__file__).resolve().parent
try:
config = _load_config(project_root)
source_issues = validate_sources(
config=config,
report_month=month,
report_year=year,
project_root=project_root,
)
_print_issues(source_issues, "=== Source validation ===")
if any(issue.severity == "error" for issue in source_issues):
raise click.ClickException("Source validation failed.")
allocation_records = parse_allocation_records(
config=config,
report_month=month,
report_year=year,
project_root=project_root,
)
masterdata_records = parse_masterdata_records(
config=config,
project_root=project_root,
)
matched_assignments, match_issues = match_assignments(
allocation_records=allocation_records,
masterdata_records=masterdata_records,
config=config,
)
_print_issues(match_issues, "=== Matching issues ===")
business_issues = validate_business_rules(
config=config,
report_month=month,
report_year=year,
allocation_records=allocation_records,
matched_assignments=matched_assignments,
project_root=project_root,
)
_print_issues(business_issues, "=== Business validation ===")
all_issues = [*match_issues, *business_issues]
has_errors = any(issue.severity == "error" for issue in all_issues)
if has_errors and not skip_errors:
raise click.ClickException("Validation failed after matching.")
if has_errors and skip_errors:
click.echo("Warnhinweis: Fehler vorhanden. Weiterfahrt mit --skip-errors (nur gematchte Faelle werden erzeugt).")
filename_pattern = config["output"]["filename_pattern"]
unique_employees = sorted({item.allocation.employee_name for item in matched_assignments})
planned_files = [
build_output_filename(
filename_pattern=filename_pattern,
employee_name=employee_name,
report_year=year,
report_month=month,
)
for employee_name in unique_employees
]
_print_preview(
month=month,
year=year,
matched_assignments=matched_assignments,
planned_files=planned_files,
)
if not yes:
confirmed = click.confirm("Dateien jetzt schreiben?", default=False)
if not confirmed:
click.echo("Abbruch ohne Schreibzugriff.")
return
employee_workbooks = build_employee_workbooks(
config=config,
matched_assignments=matched_assignments,
report_month=month,
report_year=year,
project_root=project_root,
)
output_paths = save_employee_workbooks(
config=config,
employee_workbooks=employee_workbooks,
report_month=month,
report_year=year,
project_root=project_root,
)
click.echo("=== Generated files ===")
for output_path in output_paths:
click.echo(f"- {output_path}")
except TaetigkeitsnachweisError as exc:
raise click.ClickException(str(exc)) from exc
if __name__ == "__main__":
main()
+86 -69
View File
@@ -1,8 +1,9 @@
"""Record matching logic across allocation and master data.""" """Record matching logic across allocation and master data."""
from __future__ import annotations from __future__ import annotations
import re import re
import unicodedata
from typing import Any from typing import Any
from .errors import ValidationError from .errors import ValidationError
@@ -14,22 +15,55 @@ 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)
def _normalize_name(name: str) -> str: def _normalize_name(name: str) -> str:
compact = re.sub(r"\s+", " ", name.strip()) compact = re.sub(r"\s+", " ", name.strip())
return compact.casefold() no_accents = "".join(
ch for ch in unicodedata.normalize("NFKD", compact) if not unicodedata.combining(ch)
)
return no_accents.casefold()
def _with_tolerance( def _name_keys(name: str) -> set[str]:
value: float, normalized = _normalize_name(name)
target: float, keys = {normalized}
tolerance: float, if "," in normalized:
) -> bool: last_name, first_name = [part.strip() for part in normalized.split(",", 1)]
return abs(value - target) <= tolerance if first_name and last_name:
keys.add(f"{first_name} {last_name}")
keys.add(f"{last_name}, {first_name}")
else:
tokens = normalized.split()
if len(tokens) >= 2:
first_name = tokens[0]
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 _disambiguate_by_project_key(
candidates: list[MasterDataRecord],
project_key: str,
) -> MasterDataRecord | None:
"""Return the single candidate matching allocation project key via masterdata project_scope."""
parts = [
token.strip().casefold()
for token in re.split(r"[/,;|]", project_key)
if token.strip()
]
project_tokens = set(parts or [project_key.strip().casefold()])
matched = []
for candidate in candidates:
candidate_scope = (candidate.project_scope or "PS").strip().casefold()
if candidate_scope in project_tokens:
matched.append(candidate)
return matched[0] if len(matched) == 1 else None
def match_assignments( def match_assignments(
@@ -37,20 +71,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:
master_by_name.setdefault(_normalize_name(record.name), []).append(record) 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] = [] matched: list[MatchedAssignment] = []
issues: list[ValidationIssue] = [] issues: list[ValidationIssue] = []
for allocation in allocation_records: for allocation in allocation_records:
normalized_name = _normalize_name(allocation.employee_name) # Collect all master-data candidates for this employee name
candidates = master_by_name.get(normalized_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: if not candidates:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
@@ -65,66 +112,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 explicit project scope column.
] selected = _disambiguate_by_project_key(candidates, allocation.project_key)
if selected is None:
issues.append(
ValidationIssue(
code="AMBIGUOUS_MASTERDATA",
message=(
f"Ambiguous master-data for '{allocation.employee_name}' / "
f"'{allocation.project_key}': {len(candidates)} rows, "
f"none or multiple match project_scope. "
f"project_scope values: {[c.project_scope for c in candidates]}"
),
severity="error",
context=allocation.employee_name,
)
)
continue
if len(share_matches) == 1: foerderquote = calculate_foerderquote_percent(allocation)
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(
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
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
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",
+1
View File
@@ -19,6 +19,7 @@ class MasterDataRecord:
antragsnummer: str antragsnummer: str
name: str name: str
qualifikation: str qualifikation: str
project_scope: str
stellenanteil_percent: float stellenanteil_percent: float
funktion: str funktion: str
taetigkeit: str taetigkeit: str
+24 -10
View File
@@ -1,4 +1,4 @@
"""Parsing and normalization for source workbooks.""" """Parsing and normalization for source workbooks."""
from __future__ import annotations from __future__ import annotations
@@ -30,6 +30,17 @@ def _to_float(value: Any, field_name: str) -> float:
raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc
def _to_float_or_none(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
text = str(value).strip().replace(",", ".")
try:
return float(text)
except ValueError:
return None
def _parse_german_decimal( def _parse_german_decimal(
value: Any, value: Any,
field_name: str, field_name: str,
@@ -164,10 +175,7 @@ def parse_allocation_records(
if row_employee: if row_employee:
current_employee = row_employee current_employee = row_employee
current_contract_hours = _to_float( current_contract_hours = _to_float_or_none(row_contract)
row_contract,
f"contract_hours_weekly row {row_index}",
)
if not row_project: if not row_project:
continue continue
@@ -235,6 +243,7 @@ def parse_masterdata_records(
antragsnummer_col = column_index_from_string(columns_cfg["antragsnummer"]) antragsnummer_col = column_index_from_string(columns_cfg["antragsnummer"])
name_col = column_index_from_string(columns_cfg["name"]) name_col = column_index_from_string(columns_cfg["name"])
qualifikation_col = column_index_from_string(columns_cfg["qualifikation"]) qualifikation_col = column_index_from_string(columns_cfg["qualifikation"])
project_scope_col = column_index_from_string(columns_cfg["project_scope"])
stellenanteil_col = column_index_from_string(columns_cfg["stellenanteil"]) stellenanteil_col = column_index_from_string(columns_cfg["stellenanteil"])
funktion_col = column_index_from_string(columns_cfg["funktion"]) funktion_col = column_index_from_string(columns_cfg["funktion"])
taetigkeit_col = column_index_from_string(columns_cfg["taetigkeit"]) taetigkeit_col = column_index_from_string(columns_cfg["taetigkeit"])
@@ -246,12 +255,16 @@ def parse_masterdata_records(
if not name: if not name:
continue continue
stellenanteil = _parse_german_decimal( stellenanteil_raw = worksheet.cell(row=row_index, column=stellenanteil_col).value
worksheet.cell(row=row_index, column=stellenanteil_col).value, stellenanteil = _to_float_or_none(stellenanteil_raw)
field_name=f"stellenanteil row {row_index}", if stellenanteil is None:
decimal_separator=decimal_cfg.get("decimal_separator", ","), stellenanteil = 0.0
thousands_separator=decimal_cfg.get("thousands_separator", "."),
project_scope = _to_text(
worksheet.cell(row=row_index, column=project_scope_col).value
) )
if not project_scope:
project_scope = "PS"
records.append( records.append(
MasterDataRecord( MasterDataRecord(
@@ -263,6 +276,7 @@ def parse_masterdata_records(
qualifikation=_to_text( qualifikation=_to_text(
worksheet.cell(row=row_index, column=qualifikation_col).value worksheet.cell(row=row_index, column=qualifikation_col).value
), ),
project_scope=project_scope,
stellenanteil_percent=stellenanteil, stellenanteil_percent=stellenanteil,
funktion=_to_text(worksheet.cell(row=row_index, column=funktion_col).value), funktion=_to_text(worksheet.cell(row=row_index, column=funktion_col).value),
taetigkeit=_to_text( taetigkeit=_to_text(
+155 -6
View File
@@ -9,7 +9,7 @@ from typing import Any
from openpyxl import load_workbook from openpyxl import load_workbook
from openpyxl.utils.cell import column_index_from_string from openpyxl.utils.cell import column_index_from_string
from .models import ValidationIssue from .models import AllocationRecord, MatchedAssignment, ValidationIssue
def _to_text(value: Any) -> str: def _to_text(value: Any) -> str:
@@ -132,6 +132,7 @@ def validate_sources(
master_cols["antragsnummer"], master_cols["antragsnummer"],
master_cols["name"], master_cols["name"],
master_cols["qualifikation"], master_cols["qualifikation"],
master_cols["project_scope"],
master_cols["stellenanteil"], master_cols["stellenanteil"],
master_cols["funktion"], master_cols["funktion"],
master_cols["taetigkeit"], master_cols["taetigkeit"],
@@ -194,22 +195,31 @@ def validate_sources(
if len(month_matches) == 1: if len(month_matches) == 1:
month_column = month_matches[0] month_column = month_matches[0]
employee_col = column_index_from_string(allocation_cols["employee_name"])
contract_col = column_index_from_string(allocation_cols["contract_hours_weekly"]) contract_col = column_index_from_string(allocation_cols["contract_hours_weekly"])
project_col = column_index_from_string(allocation_cols["project_key"]) project_col = column_index_from_string(allocation_cols["project_key"])
current_contract_value: float | None = None
for row_idx in range(header_row + 1, allocation_ws.max_row + 1): for row_idx in range(header_row + 1, allocation_ws.max_row + 1):
employee_value = _to_text(allocation_ws.cell(row=row_idx, column=employee_col).value)
contract_raw = allocation_ws.cell(row=row_idx, column=contract_col).value
parsed_contract = _to_float_or_none(contract_raw)
if employee_value:
current_contract_value = parsed_contract
project_key = _to_text(allocation_ws.cell(row=row_idx, column=project_col).value) project_key = _to_text(allocation_ws.cell(row=row_idx, column=project_col).value)
if not project_key: if not project_key:
continue continue
contract_value = _to_float_or_none( if current_contract_value is None or current_contract_value <= 0:
allocation_ws.cell(row=row_idx, column=contract_col).value
)
if contract_value is None or contract_value <= 0:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
code="INVALID_CONTRACT_HOURS", code="INVALID_CONTRACT_HOURS",
message=f"Invalid contract weekly hours at row {row_idx}.", message=(
f"Invalid inherited contract weekly hours at row {row_idx}."
),
severity="error", severity="error",
) )
) )
@@ -232,3 +242,142 @@ def validate_sources(
allocation_wb.close() allocation_wb.close()
master_wb.close() master_wb.close()
return issues return issues
def _split_name_for_filename(name: str) -> tuple[str, str]:
cleaned = _to_text(name)
if "," in cleaned:
last_name, first_name = [part.strip() for part in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _build_output_filename(pattern: str, year: int, month: int, employee_name: str) -> str:
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_")
safe_last = last_name.replace(" ", "_")
return pattern.format(
year=year,
month=f"{month:02d}",
first_name=safe_first,
last_name=safe_last,
)
def validate_business_rules(
config: dict[str, Any],
report_month: int,
report_year: int,
allocation_records: list[AllocationRecord],
matched_assignments: list[MatchedAssignment],
project_root: Path,
) -> list[ValidationIssue]:
issues: list[ValidationIssue] = []
if len(allocation_records) == 0:
issues.append(
ValidationIssue(
code="NO_ACTIVE_ASSIGNMENT",
message="No active allocation record found for the requested month.",
severity="error",
)
)
matched_keys: set[tuple[str, str, float]] = set()
for item in matched_assignments:
key = (
item.allocation.employee_name,
item.allocation.project_key,
item.allocation.funded_hours_weekly,
)
if key in matched_keys:
issues.append(
ValidationIssue(
code="DUPLICATE_MATCH",
message=(
f"Duplicate matched assignment for '{item.allocation.employee_name}' "
f"and project '{item.allocation.project_key}'."
),
severity="error",
context=item.allocation.employee_name,
)
)
matched_keys.add(key)
paths_cfg = config["paths"]
files_cfg = config["files"]
template_path = (
project_root
/ paths_cfg["template_dir"]
/ files_cfg["template_workbook"]
)
if template_path.exists():
template_wb = load_workbook(template_path, data_only=True)
configured_sheet = config["sheets"].get("template_source", "")
if configured_sheet not in template_wb.sheetnames:
issues.append(
ValidationIssue(
code="TEMPLATE_SHEET_MISSING",
message=(
f"Configured template sheet '{configured_sheet}' not found in template workbook."
),
severity="error",
)
)
template_wb.close()
else:
issues.append(
ValidationIssue(
code="MISSING_TEMPLATE",
message=f"Template workbook not found: {template_path}",
severity="error",
)
)
template_targets = config.get("template_targets", {})
missing_targets = [name for name, cell in template_targets.items() if not _to_text(cell)]
if missing_targets:
issues.append(
ValidationIssue(
code="MISSING_TEMPLATE_TARGETS",
message=(
"Mandatory template target cells not configured: "
+ ", ".join(missing_targets)
),
severity="error",
)
)
filename_pattern = config["output"]["filename_pattern"]
filename_to_employee: dict[str, str] = {}
for assignment in matched_assignments:
filename = _build_output_filename(
filename_pattern,
report_year,
report_month,
assignment.allocation.employee_name,
)
previous_owner = filename_to_employee.get(filename)
if previous_owner is None:
filename_to_employee[filename] = assignment.allocation.employee_name
continue
if previous_owner != assignment.allocation.employee_name:
issues.append(
ValidationIssue(
code="FILENAME_COLLISION",
message=(
f"Output filename collision between '{previous_owner}' and "
f"'{assignment.allocation.employee_name}' -> {filename}"
),
severity="error",
)
)
return issues
+152
View File
@@ -1 +1,153 @@
"""Template-based output workbook writer.""" """Template-based output workbook writer."""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from pathlib import Path
from typing import Any
from openpyxl import Workbook, load_workbook
from .models import MatchedAssignment
def _split_name_for_filename(name: str) -> tuple[str, str]:
cleaned = str(name or "").strip()
if "," in cleaned:
last_name, first_name = [part.strip() for part in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _normalize_sheet_title(raw_title: str, fallback_index: int) -> str:
cleaned = (raw_title or "").strip().replace("[", "(").replace("]", ")")
if not cleaned:
cleaned = f"Assignment_{fallback_index}"
return cleaned[:31]
def _set_target_value(worksheet: Any, target_cell: str, value: Any) -> None:
if not target_cell:
return
worksheet[target_cell] = value
def build_employee_workbooks(
config: dict[str, Any],
matched_assignments: list[MatchedAssignment],
report_month: int,
report_year: int,
project_root: Path,
) -> dict[str, Workbook]:
paths_cfg = config["paths"]
files_cfg = config["files"]
output_cfg = config["output"]
targets = config.get("template_targets", {})
template_path = (
project_root
/ paths_cfg["template_dir"]
/ files_cfg["template_workbook"]
)
assignments_by_employee: dict[str, list[MatchedAssignment]] = defaultdict(list)
for assignment in matched_assignments:
assignments_by_employee[assignment.allocation.employee_name].append(assignment)
workbooks: dict[str, Workbook] = {}
month_label = f"{report_month:02d}/{report_year}"
today_label = date.today().strftime("%d.%m.%Y")
for employee_name, assignments in assignments_by_employee.items():
workbook = load_workbook(template_path)
template_sheet_name = config["sheets"].get("template_source", "")
if template_sheet_name and template_sheet_name in workbook.sheetnames:
base_sheet = workbook[template_sheet_name]
else:
base_sheet = workbook.worksheets[0]
for index, assignment in enumerate(assignments, start=1):
if index == 1:
sheet = base_sheet
else:
sheet = workbook.copy_worksheet(base_sheet)
sheet_name_pattern = output_cfg.get("sheet_name_pattern", "{index}_{funktion}")
raw_sheet_name = sheet_name_pattern.format(
index=index,
funktion=assignment.masterdata.funktion,
)
sheet.title = _normalize_sheet_title(raw_sheet_name, index)
_set_target_value(sheet, targets.get("antragsnummer", ""), assignment.masterdata.antragsnummer)
_set_target_value(sheet, targets.get("name_vorname", ""), assignment.allocation.employee_name)
_set_target_value(sheet, targets.get("qualifikation", ""), assignment.masterdata.qualifikation)
_set_target_value(sheet, targets.get("funktion", ""), assignment.masterdata.funktion)
_set_target_value(sheet, targets.get("taetigkeiten", ""), assignment.masterdata.taetigkeit)
_set_target_value(sheet, targets.get("reporting_month_label", ""), month_label)
_set_target_value(sheet, targets.get("foerderquote", ""), f"{assignment.foerderquote_percent:.2f} %")
_set_target_value(sheet, targets.get("generated_on", ""), today_label)
workbooks[employee_name] = workbook
return workbooks
def build_output_filename(
filename_pattern: str,
employee_name: str,
report_year: int,
report_month: int,
) -> str:
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_")
safe_last = last_name.replace(" ", "_")
return filename_pattern.format(
year=report_year,
month=f"{report_month:02d}",
first_name=safe_first,
last_name=safe_last,
)
def save_employee_workbooks(
config: dict[str, Any],
employee_workbooks: dict[str, Workbook],
report_month: int,
report_year: int,
project_root: Path,
) -> list[Path]:
paths_cfg = config["paths"]
output_cfg = config["output"]
filename_pattern = output_cfg["filename_pattern"]
output_dir = project_root / paths_cfg["output_dir"]
output_dir.mkdir(parents=True, exist_ok=True)
saved_paths: list[Path] = []
used_names: set[str] = set()
for employee_name, workbook in employee_workbooks.items():
filename = build_output_filename(
filename_pattern=filename_pattern,
employee_name=employee_name,
report_year=report_year,
report_month=report_month,
)
if filename in used_names:
raise ValueError(f"Output filename collision detected: {filename}")
used_names.add(filename)
output_path = output_dir / filename
workbook.save(output_path)
workbook.close()
saved_paths.append(output_path)
return saved_paths