diff --git a/.github/copilot-tasks.md b/.github/copilot-tasks.md index 1bce20e..aa222bf 100644 --- a/.github/copilot-tasks.md +++ b/.github/copilot-tasks.md @@ -59,12 +59,12 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon - [x] Monatsmarker parsebar - [x] Gewuenschter Monat exakt einmal vorhanden - [x] Stunden-/Vertragswerte gueltig -- [ ] T10: Business-Validierung implementieren - - [ ] Mindestens ein aktiver Fall vorhanden - - [ ] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler - - [ ] Template enthaelt genau ein Quellblatt - - [ ] Dateinamenkollisionen vermeiden - - [ ] Pflicht-Zielzellen vollstaendig konfiguriert +- [x] T10: Business-Validierung implementieren + - [x] Mindestens ein aktiver Fall vorhanden + - [x] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler + - [x] Template enthaelt genau ein Quellblatt + - [x] Dateinamenkollisionen vermeiden + - [x] Pflicht-Zielzellen vollstaendig konfiguriert ## Phase 6 - Ausgabeerzeugung - [ ] T11: Template-Verarbeitung bauen @@ -111,4 +111,4 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon 8. T15 -> T16 -> T17 ## Aktueller Startpunkt -- [ ] Naechste aktive Aufgabe: T10 (Business-Validierung implementieren) +- [ ] Naechste aktive Aufgabe: T11 (Template-Verarbeitung bauen) diff --git a/src/taetigkeitsnachweis/validator.py b/src/taetigkeitsnachweis/validator.py index b2e47d6..8918c91 100644 --- a/src/taetigkeitsnachweis/validator.py +++ b/src/taetigkeitsnachweis/validator.py @@ -9,7 +9,7 @@ from typing import Any from openpyxl import load_workbook 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: @@ -232,3 +232,141 @@ def validate_sources( allocation_wb.close() master_wb.close() 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], +) -> 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"] + project_root = Path.cwd() + 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) + if len(template_wb.sheetnames) != 1: + issues.append( + ValidationIssue( + code="TEMPLATE_SHEET_COUNT", + message=( + f"Template workbook must contain exactly one sheet, found {len(template_wb.sheetnames)}." + ), + 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