Implement business validation rules for output generation
This commit is contained in:
1 parent
70873f3726
commit
7501499080
2 files changed
+146
-8
No files matched your search
@@ -59,12 +59,12 @@ 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
|
- [ ] T11: Template-Verarbeitung bauen
|
||||||
@@ -111,4 +111,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: T11 (Template-Verarbeitung bauen)
|
||||||
@@ -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:
|
||||||
@@ -232,3 +232,141 @@ 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],
|
||||||
|
) -> 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
|
||||||
Reference in new issue
Block a user