diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c35f97b..0ef1786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,8 +118,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.ref_name }} AndiGeneratorNET-${{ steps.version.outputs.version }}-sbom.spdx.json - # Der Abschnitt „## “ aus CHANGELOG.md wird zum Text des Releases. Fehlt er, bleibt der Text leer. - - name: Release Notes aus CHANGELOG.md + # Text des Releases: oben ein Wegweiser „Welche Datei?“, darunter der Abschnitt „## “ aus CHANGELOG.md. + - name: Release-Text (Wegweiser und Änderungen) continue-on-error: true shell: pwsh env: @@ -131,16 +131,33 @@ jobs: for ($i = 0; $i -lt $zeilen.Count; $i++) { if ($zeilen[$i] -match ('^## ' + [regex]::Escape($version) + '(\s|$)')) { $start = $i; break } } - if ($start -lt 0) { - Write-Host "Kein Abschnitt '## $version' in CHANGELOG.md - der Release-Text bleibt leer." - exit 0 + $aenderungen = '' + if ($start -ge 0) { + $ende = $zeilen.Count + for ($i = $start + 1; $i -lt $zeilen.Count; $i++) { + if ($zeilen[$i] -match '^## ') { $ende = $i; break } + } + if ($ende -gt $start + 1) { $aenderungen = ($zeilen[($start + 1)..($ende - 1)] -join "`n").Trim() } + } else { + Write-Host "Kein Abschnitt '## $version' in CHANGELOG.md - nur der Wegweiser." } - $ende = $zeilen.Count - for ($i = $start + 1; $i -lt $zeilen.Count; $i++) { - if ($zeilen[$i] -match '^## ') { $ende = $i; break } - } - $text = '' - if ($ende -gt $start + 1) { $text = ($zeilen[($start + 1)..($ende - 1)] -join "`n").Trim() } + $basis = '${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/' + $wegweiser = @( + '## Welche Datei brauche ich?', + '', + '| Ihr Rechner | Datei |', + '|---|---|', + "| Windows-PC oder -Laptop (Intel/AMD – der Normalfall) | [AndiGeneratorNET-win-Setup.exe]($($basis)AndiGeneratorNET-win-Setup.exe) |", + "| Windows-Laptop mit ARM-Prozessor (z. B. Snapdragon) | [AndiGeneratorNET-win-arm64-Setup.exe]($($basis)AndiGeneratorNET-win-arm64-Setup.exe) |", + "| Mac mit Apple-Chip (M1 und neuer) | [AndiGeneratorNET-macos-arm64.dmg]($($basis)AndiGeneratorNET-macos-arm64.dmg) |", + "| Mac mit Intel-Prozessor | [AndiGeneratorNET-macos-x64.dmg]($($basis)AndiGeneratorNET-macos-x64.dmg) |", + "| Chromebook oder Linux mit Intel/AMD | [andigenerator-net_amd64.deb]($($basis)andigenerator-net_amd64.deb) |", + "| Chromebook oder Linux mit ARM-Prozessor | [andigenerator-net_arm64.deb]($($basis)andigenerator-net_arm64.deb) |", + '', + 'Im Zweifel bei Windows die erste Datei: Sie läuft auch auf ARM-Laptops mit Windows 11, nur langsamer. Ohne Installation: `…-Portable.zip` (Windows) bzw. `….tar.gz` (Linux). Die übrigen Dateien (`.nupkg`, `RELEASES`, `releases.*.json`, Stückliste) braucht das Programm für seine Updates bzw. dienen als Nachweis – nicht herunterladen. Installation und Warnungen beim ersten Start: [Anleitung, Abschnitt 2](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/Doku/ANLEITUNG.md#2-installation).' + ) -join "`n" + $text = $wegweiser + if ($aenderungen.Length -gt 0) { $text += "`n`n## Änderungen`n`n" + $aenderungen } $text += "`n`nAlle Änderungen: [CHANGELOG.md](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md)" Set-Content -Path release-notes.md -Value $text -Encoding utf8 gh release edit ${{ github.ref_name }} --notes-file release-notes.md diff --git a/.gitignore b/.gitignore index 93cc6d1..53b3f77 100644 --- a/.gitignore +++ b/.gitignore @@ -64,10 +64,13 @@ nunit-*.xml build-log.txt build-messung.txt optimierung-log.txt +turnier-log.txt abdeckung-log.txt messen-log.txt inselvergleich-*.txt korrekturvergleich/ +hallenvergleich/ +pdftext-log.txt bildschirmfotos-log.txt # Private Originaldaten (Klarnamen) – öffentlich nur die anonymisierte Kopie unter testdaten/ diff --git a/AndiGenerator.slnx b/AndiGenerator.slnx index 6a7cebe..6e5b203 100644 --- a/AndiGenerator.slnx +++ b/AndiGenerator.slnx @@ -3,6 +3,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9974ceb..e0d303f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Was sich von Version zu Version für Anwender ändert. Die Einzelheiten stehen in der [Anleitung](Doku/ANLEITUNG.md), die technische Geschichte im [Migrationsplan](Doku/MIGRATIONSPLAN.md). +## 0.9.7 (12.10.2026) + +### Neu + +- **Daten aus click-TT ergänzen** (erster Punkt der Einrichtung): Die **Mannschaftsmeldung** (CSV) ergibt aus den TTR-Werten der Stammspieler einen Vorschlag für die Setzliste. Der Bericht **Terminwünsche** (PDF) liefert die Spiellokal-Nummern für den Export, die Adressen der Hallen und die Bemerkungen der Vereine und zeigt Unterschiede zu den Spielplandaten. Erledigt ist der Punkt, wenn beide Dateien übernommen sind – auch in verschiedenen Sitzungen. Die Einrichtung lässt sich abbrechen. +- **Prüfung der 60-km-Regel:** Entfernung der Hallen in Luftlinie (aus Postleitzahl und Ort, deutschlandweit und ohne Internet, oder aus der GPS-Angabe des Vereins). Hinweise „Sperre fehlt vermutlich“ und „Sperre vermutlich unnötig“ für Gastgeber mit Heimterminen unter der Woche und am Wochenende; fehlende Sperren lassen sich mit einer Rückfrage setzen, ein Protokoll zeigt welche. +- Seite **Wochenende (60-km-Regel)**: Entfernung je Gegner, Marken „weit“ und „nah“ und die Summe aller Entfernungen. +- **Hallenbelegung nach Hallennamen:** Ein Heimspiel einer Nachbarmannschaft in einer anderen Halle belegt die eigene Halle nicht mehr. + +### Geändert + +- Nach dem Laden einer Datei erscheint eine deutlich sichtbare Meldung direkt unter dem Knopf. +- In den Kacheln *Verbesserungen* und *Berechnete Pläne* steht „Details ›“ wie in der Ergebniskachel oben rechts. +- **Download einfacher:** Oben im Release steht, welche Datei man für welchen Rechner braucht; README und Anleitung haben dieselbe Tabelle mit festen Links auf die jeweils neueste Version. Dafür tragen die Dateien für Mac und Linux keine Versionsnummer mehr im Namen. + ## 0.9.6 (12.10.2026) ### Neu diff --git a/Directory.Packages.props b/Directory.Packages.props index a0afaf0..54e1f88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,10 @@ + + + + diff --git a/Doku/ANLEITUNG.md b/Doku/ANLEITUNG.md index dd913dc..6efc68b 100644 --- a/Doku/ANLEITUNG.md +++ b/Doku/ANLEITUNG.md @@ -40,6 +40,19 @@ Wenn Sie vorher mit dem Original-AndiGenerator gearbeitet haben, übernimmt Andi AndiGenerator.NET läuft unter Windows 10 und 11 (64 Bit), auf dem Mac (macOS 14 oder neuer), auf Chromebooks und unter Linux (siehe unten). Administratorrechte sind nicht nötig, und .NET muss nicht installiert sein – alles Nötige ist dabei. +**Welche Datei?** Ein Klick lädt jeweils die neueste Version: + +| Ihr Rechner | Datei | +|---|---| +| Windows-PC oder -Laptop (Intel/AMD – der Normalfall) | [AndiGeneratorNET-win-Setup.exe](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-win-Setup.exe) | +| Windows-Laptop mit ARM-Prozessor (z. B. Snapdragon) | [AndiGeneratorNET-win-arm64-Setup.exe](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-win-arm64-Setup.exe) | +| Mac mit Apple-Chip (M1 und neuer) | [AndiGeneratorNET-macos-arm64.dmg](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-macos-arm64.dmg) | +| Mac mit Intel-Prozessor | [AndiGeneratorNET-macos-x64.dmg](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-macos-x64.dmg) | +| Chromebook oder Linux mit Intel/AMD | [andigenerator-net_amd64.deb](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/andigenerator-net_amd64.deb) | +| Chromebook oder Linux mit ARM-Prozessor | [andigenerator-net_arm64.deb](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/andigenerator-net_arm64.deb) | + +Im Zweifel bei Windows die erste Datei nehmen: Sie läuft auch auf ARM-Laptops mit Windows 11, nur langsamer. Die übrigen Dateien auf der Release-Seite (`.nupkg`, `RELEASES`, `releases.*.json`, Stückliste) braucht das Programm für seine Updates bzw. dienen als Nachweis – sie müssen Sie nicht herunterladen. + **Mit Setup (empfohlen)** 1. Auf der Seite [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases) bei der neuesten Version die Datei `AndiGeneratorNET-win-Setup.exe` herunterladen. Für Windows-Laptops mit ARM-Prozessor (z. B. Snapdragon) gibt es `AndiGeneratorNET-win-arm64-Setup.exe`; sie rechnet dort deutlich schneller. Welcher Prozessor eingebaut ist, steht unter *Einstellungen → System → Info → Systemtyp* („ARM-basierter Prozessor“). @@ -63,7 +76,7 @@ Das installierte Programm sucht nach dem Start selbst nach einer neuen Version u **Auf dem Mac** -1. Auf der Seite [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases) das passende Disk-Image herunterladen: `AndiGeneratorNET--macos-arm64.dmg` für Macs mit Apple-Chip (M1 und neuer), `…-macos-x64.dmg` für ältere Macs mit Intel-Prozessor. Welcher Chip eingebaut ist, steht unter * → Über diesen Mac*. +1. Auf der Seite [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases) das passende Disk-Image herunterladen: `AndiGeneratorNET-macos-arm64.dmg` für Macs mit Apple-Chip (M1 und neuer), `AndiGeneratorNET-macos-x64.dmg` für ältere Macs mit Intel-Prozessor. Welcher Chip eingebaut ist, steht unter * → Über diesen Mac*. 2. Das Disk-Image öffnen und **AndiGenerator.NET** auf den Ordner **Programme** ziehen. 3. Beim ersten Start meldet macOS, dass Apple das Programm nicht überprüfen konnte, und öffnet es nicht – das Programm ist noch nicht von Apple beglaubigt. Die Meldung mit **Fertig** schließen, dann *Systemeinstellungen → Datenschutz & Sicherheit* öffnen, nach unten blättern und bei AndiGenerator.NET **Dennoch öffnen** wählen und mit dem Passwort bestätigen. Danach startet das Programm normal. @@ -74,14 +87,14 @@ Statt **Strg** gilt auf dem Mac die Befehlstaste **⌘** (z. B. ⌘ + Plus zum V Auf Chromebooks läuft das Programm in der Linux-Entwicklungsumgebung, die ChromeOS mitbringt. 1. Einmalig Linux einschalten: *Einstellungen → Info zu ChromeOS → Entwickler → Linux-Entwicklungsumgebung → Aktivieren* (bei älteren Versionen *Einstellungen → Erweitert → Entwickler*). -2. Auf der Seite [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases) das passende Paket herunterladen: `andigenerator-net__amd64.deb` für Chromebooks mit Intel- oder AMD-Prozessor, `…_arm64.deb` für Chromebooks mit ARM-Prozessor (z. B. MediaTek oder Qualcomm). Welcher Prozessor eingebaut ist, steht unter *Einstellungen → Info zu ChromeOS → Weitere Details*, oder im Linux-Terminal mit `dpkg --print-architecture`. +2. Auf der Seite [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases) das passende Paket herunterladen: `andigenerator-net_amd64.deb` für Chromebooks mit Intel- oder AMD-Prozessor, `andigenerator-net_arm64.deb` für Chromebooks mit ARM-Prozessor (z. B. MediaTek oder Qualcomm). Welcher Prozessor eingebaut ist, steht unter *Einstellungen → Info zu ChromeOS → Weitere Details*, oder im Linux-Terminal mit `dpkg --print-architecture`. 3. Die Datei in der App *Dateien* in den Ordner *Linux-Dateien* ziehen. 4. Das *Terminal* öffnen (im Launcher unter *Linux-Apps*) und eingeben – mit dem Dateinamen der heruntergeladenen Datei: - `sudo apt install ./andigenerator-net__amd64.deb` + `sudo apt install ./andigenerator-net_amd64.deb` (bzw. `…_arm64.deb`) ChromeOS bietet die Installation per Doppelklick nicht mehr an. Das Programm erscheint danach im Launcher im Ordner *Linux-Apps*. 5. Die click-TT-Dateien müssen für Linux sichtbar sein: entweder in den Ordner *Linux-Dateien* legen oder einen Ordner in *Dateien* mit der rechten Maustaste **Mit Linux teilen**. -Ohne Installation geht es mit `AndiGeneratorNET--linux-x64.tar.gz` bzw. `…-linux-arm64.tar.gz`: entpacken und `./AndiGenerator.NET` starten. Unter Linux liegen die eigenen Daten im Ordner `~/.local/share/AndiGenerator.NET`. Neue Versionen werden hier nicht selbst gesucht – zum Aktualisieren das neue Paket genauso installieren. +Ohne Installation geht es mit `AndiGeneratorNET-linux-x64.tar.gz` bzw. `AndiGeneratorNET-linux-arm64.tar.gz`: entpacken und `./AndiGenerator.NET` starten. Unter Linux liegen die eigenen Daten im Ordner `~/.local/share/AndiGenerator.NET`. Neue Versionen werden hier nicht selbst gesucht – zum Aktualisieren das neue Paket genauso installieren. ## 3. Erste Schritte @@ -115,16 +128,31 @@ click-TT überträgt nicht alles, was für einen guten Plan nötig ist, und die | Punkt | Worum es geht | |---|---| +| **Daten aus click-TT ergänzen** | Immer als erster Punkt: weitere Exporte aus click-TT, die nicht in der XML-Datei stehen (siehe unten). Jeder ist freiwillig. Erledigt ist der Punkt erst, wenn Mannschaftsmeldung **und** Terminwünsche übernommen sind – das darf auch in verschiedenen Sitzungen geschehen; die Seite nennt bei jeder Datei, wann sie übernommen wurde. Ist beides erledigt und sonst nichts zu klären, erscheint die Einrichtung nicht. | | **Gespeicherte Einstellungen** | Sie haben die Staffel schon einmal bearbeitet und dabei Gewichtungen geändert. Wählen Sie, ob Sie diese weiterverwenden oder für diese Sitzung mit den Standardeinstellungen rechnen wollen (die gespeicherten bleiben erhalten). | | **Gespeicherte Kriterienreihenfolge** | Sie haben für diese Staffel in der Ansicht **Qualität** eine eigene Einteilung der Kriterien in die Stufen A, B und C festgelegt (danach richtet sich der Automodus). Die Seite zeigt sie; wählen Sie, ob Sie sie weiterverwenden oder für diese Sitzung die Standardreihenfolge nehmen (die gespeicherte bleibt erhalten, bis Sie die Einteilung wieder ändern). | | **Rundenplanung** | Ist die Runde kürzer als sechs Monate, schlägt das Programm eine Halbrunde vor. Beginnt die Rückrunde in weniger als 60 Tagen (oder hat sie vor weniger als 60 Tagen begonnen), schlägt es vor, nur die Rückrunde zu planen; die Frage nennt dazu den Beginn der Rückrunde. | -| **Spiellokale** | click-TT überträgt keine Spiellokale. Haben Vereine mehrere Hallen, tragen Sie hier ein, welche Mannschaft wo spielt – sonst stimmt die Berechnung der Hallenbelegung nicht. Haben alle Vereine nur eine Halle, können Sie den Punkt überspringen. | +| **Spiellokale** | click-TT überträgt keine Nummern der Spiellokale, wohl aber die Namen der Hallen. Das Programm vergleicht die Hallenbelegung deshalb nach dem Hallennamen: Ein Heimspiel einer Nachbarmannschaft in einer anderen Halle belegt die eigene Halle nicht. Die Nummern (1, 2, 3) brauchen Sie für den Export nach click-TT und für von Hand angelegte Termine ohne Hallennamen. Haben alle Vereine nur eine Halle, können Sie den Punkt überspringen. | | **Koppeltermine** | Die Staffel enthält Koppelwünsche oder mögliche Doppelspieltage. Das Programm leitet sie aus den Meldungen ab; bitte kontrollieren. | | **Auswärtskoppelwünsche** | Mannschaften möchten zwei Auswärtsspiele an einem Wochenende zusammenlegen. Bitte kontrollieren. | | **Setzliste** | Wenn Sie mit einer Setzliste arbeiten wollen, geben Sie hier die erwartete Abschlusstabelle vor. | | **Pflichtspieltage** | Die Staffel enthält Pflichtspieltage. Sie sind bei ungerader Mannschaftszahl problematisch (siehe [Pflichtspieltage](#pflichtspieltage)). | -Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung; die Generierung startet danach im Hauptfenster mit **▶ Start** (Verfahren daneben wählen), wo auch der Ausgangsplan gewählt wird. Bei Entscheidungen gilt dabei die angeklickte Wahl, auch ohne **Auswahl übernehmen**; Punkte, bei denen Sie nichts gewählt haben, bleiben unverändert. Die Wahlkacheln und Knöpfe stehen immer unten im Blick, auch wenn die Erklärung darüber lang ist. Alles lässt sich später unter **Spielplandaten**, **Einstellungen** und in der Ansicht **Qualität** ändern. +Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung; **Abbrechen** schließt die Staffel wieder (schon übernommene Punkte bleiben gespeichert). Solange die Einrichtung offen ist, sind auch **Öffnen** und **Neu** gesperrt – erst abschließen oder abbrechen; die Generierung startet danach im Hauptfenster mit **▶ Start** (Verfahren daneben wählen), wo auch der Ausgangsplan gewählt wird. Bei Entscheidungen gilt dabei die angeklickte Wahl, auch ohne **Auswahl übernehmen**; Punkte, bei denen Sie nichts gewählt haben, bleiben unverändert. Die Wahlkacheln und Knöpfe stehen immer unten im Blick, auch wenn die Erklärung darüber lang ist. Alles lässt sich später unter **Spielplandaten**, **Einstellungen** und in der Ansicht **Qualität** ändern. + +**Daten aus click-TT ergänzen.** Hier gibt es zwei Dateien. Die **Mannschaftsmeldung**: In click-TT stehen bei den Downloads der Staffel die Dateien **Mannschaftsmeldung Vorrunde (csv)** bzw. **Rückrunde (csv)**. Nach **Mannschaftsmeldung laden…** zeigt das Programm je Mannschaft den Schnitt der TTR-Werte ihrer besten Stammspieler – bei Vierer-Mannschaften der besten vier, bei Sechser-Mannschaften der besten sechs; Reservespieler zählen nicht – und ordnet die Mannschaften danach, die stärkste zuerst. Mit dem Haken **Als Setzliste übernehmen** wird diese Reihenfolge die Setzliste (und die Setzliste eingeschaltet); im Punkt **Setzliste** können Sie sie danach wie gewohnt verschieben. Gelesen werden nur Mannschaft, Rang, Status und TTR-Wert – keine Namen oder Geburtsdaten. + +![Daten aus click-TT: Mannschaftsmeldung als Vorschlag für die Setzliste](Anleitung/Bilder/26-daten-aus-clicktt.png) + +Die **Terminwünsche** (PDF): In click-TT bei den Downloads der Staffel **Terminmeldungen (pdf)**. Der Bericht nennt die Spiellokale 1 bis 3 jedes Vereins mit Adresse. Nach **Terminwünsche laden…** setzt das Programm bei jedem Heimtermin die Nummer des Spiellokals, dessen Name zur Halle aus click-TT passt – die Nummern braucht der Export nach click-TT, und von Hand eingetragene Nummern bleiben unverändert. Außerdem merkt es sich die Adressen (für die Entfernungen zwischen den Hallen, siehe unten), zeigt die **Bemerkungen der Vereine** – Wünsche, die click-TT nicht strukturiert überträgt, etwa „alle Spiele nur samstags“ – und listet **Unterschiede** zwischen Bericht und Spielplandaten, z. B. Heim- oder Sperrtermine, die ein Verein nach dem Herunterladen der XML-Datei geändert hat oder die Sie selbst angepasst haben. Termine und Sperren selbst ändert der Bericht nicht. Kontaktdaten werden nicht gelesen. + +Nach dem Laden erscheint direkt unter dem jeweiligen Knopf eine hervorgehobene Meldung mit grünem Haken und dem Wichtigsten (z. B. wie viele Mannschaften gefunden wurden und wie viele Unterschiede es gibt), und die Seite rollt dorthin; die Einzelheiten stehen darunter. + +![Terminwünsche geladen: Meldung, Kennzahlen und eine Karte je Mannschaft](Anleitung/Bilder/27-terminwuensche-pdf.png) + +**Wochenspiele (60-km-Regel).** Sobald Hallenadressen bekannt sind, prüft die Seite die Liste „keine Auswärtsspiele unter der Woche“ (Spielplandaten, Seite [Wochenende (60-km-Regel)](#wochenende-60-km-regel)). Gemessen wird die **Luftlinie** zwischen den Hallen, aus Postleitzahl und Ort – deutschlandweit und ohne Internet. Die Prüfung läuft sofort auf der Seite, sobald Hallenadressen bekannt sind, und erneut, wenn Sie die Grenze ändern (Vorgabe 60 km); sie ändert nichts an den Daten. Für den Plan zählen allein die Häkchen auf der Seite *Wochenende (60-km-Regel)*. Geprüft wird ein Paar nur, wenn der Gastgeber Heimtermine **unter der Woche und am Wochenende** gemeldet hat – nur dann entscheidet die Sperre, wann gespielt wird. Unter der Woche heißt Montag bis Donnerstag, der Freitag nur, wenn er in den **Einstellungen** nicht zum Wochenende zählt. Hat in der Staffel niemand solche Termine (etwa in einer Verbandsliga, die nur samstags und sonntags spielt), sagt die Seite das, und es gibt nichts zu prüfen. **Sperre fehlt vermutlich:** Die Hallen liegen weiter auseinander als die Grenze, aber der Gast hat den Gastgeber nicht gesperrt. **Sperre vermutlich unnötig:** gesperrt, obwohl die Hallen höchstens so weit auseinander liegen. Sperrt ein Verein pauschal alle Gegner, ist das sein Wunsch – die Seite nennt ihn, bewertet ihn aber nicht. Da die Wettspielordnung kein festes Maß vorgibt, sind das zunächst nur Hinweise. Mit **Fehlende Sperren setzen…** setzen Sie nach einer einzigen Rückfrage (sie listet alle Treffer) alle fehlenden Sperren auf einmal; vorhandene bleiben. Danach zeigt das **Protokoll** auf der Seite Zeitpunkt, Grenze und je Sperre „Gast spielt bei Gastgeber nur am Wochenende (km)“. Gespeichert wird mit der Seite; **Standard** nimmt die gesetzten Sperren wieder zurück. Unnötige Sperren ändert das Programm nicht. + +![Wochenspiele – Prüfung der 60-km-Regel](Anleitung/Bilder/28-wochenspiele.png) ### 3.4 Terminwünsche kontrollieren @@ -525,6 +553,10 @@ dazu die einzelnen **Begegnungen** dieser Mannschaft. Ist die Anfahrt zu einem Gegner zu weit, sollen die Spiele gegen ihn am Wochenende stattfinden. Hier legen Sie fest, für welche Paarungen das gilt. Ob der Freitag zum Wochenende zählt, stellen Sie in den **Einstellungen** ein. +Sind die Terminwünsche (PDF) übernommen (siehe [3.3](#33-die-staffel-einrichten)), steht neben jedem Gegner die Entfernung der Hallen in Luftlinie. Die Marke **weit** zeigt einen Gegner über 60 km ohne Häkchen, der auch unter der Woche Heimspiele hat; **nah** ein Häkchen bei höchstens 60 km. Beides sind nur Hinweise. Unter der Liste steht die Summe der Entfernungen zu allen Gegnern (je einfache Strecke). + +![Wochenende (60-km-Regel) mit Entfernungen](Anleitung/Bilder/29-wochenende-entfernungen.png) + ### Spielfreie Tage An einzelnen Tagen soll gar nicht gespielt werden – etwa weil die Relegationstermine noch nicht feststanden, weil nachträglich ein Spielverbot beschlossen wurde oder weil beim Anlegen der Spieltage ein Fehler passiert ist. Diese Tage wählen Sie in einem **Monatskalender** der Saison: Ein Klick auf einen Tag macht ihn spielfrei (rot), ein zweiter gibt ihn wieder frei. Wählbar sind die Tage, an denen Mannschaften Heimspielwünsche haben. Grau sind Tage, die **nicht gesperrt** sind, an denen aber keine Mannschaft einen Heimspielwunsch hat – dort wird ohnehin nicht gespielt; ist ein solcher Tag trotzdem als spielfrei eingetragen (etwa aus click-TT), ist er rot unterstrichen. Der Tooltip eines Tages nennt den Grund, die Legende steht über dem Kalender; Wochenenden sind fett. Die Monate brechen um, wenn das Fenster schmaler ist. Die Auswertung der Terminwünsche berücksichtigt die spielfreien Tage. diff --git a/Doku/Anleitung.pdf b/Doku/Anleitung.pdf index 6253068..c450da7 100644 Binary files a/Doku/Anleitung.pdf and b/Doku/Anleitung.pdf differ diff --git a/Doku/Anleitung/Bilder/02-einrichtung.png b/Doku/Anleitung/Bilder/02-einrichtung.png index 5437a74..6b3cd3f 100644 Binary files a/Doku/Anleitung/Bilder/02-einrichtung.png and b/Doku/Anleitung/Bilder/02-einrichtung.png differ diff --git a/Doku/Anleitung/Bilder/03-hauptfenster.png b/Doku/Anleitung/Bilder/03-hauptfenster.png index 227dd4d..27266f5 100644 Binary files a/Doku/Anleitung/Bilder/03-hauptfenster.png and b/Doku/Anleitung/Bilder/03-hauptfenster.png differ diff --git a/Doku/Anleitung/Bilder/04-kostenansicht.png b/Doku/Anleitung/Bilder/04-kostenansicht.png index 0ba84c0..d619e80 100644 Binary files a/Doku/Anleitung/Bilder/04-kostenansicht.png and b/Doku/Anleitung/Bilder/04-kostenansicht.png differ diff --git a/Doku/Anleitung/Bilder/05-qualitaet.png b/Doku/Anleitung/Bilder/05-qualitaet.png index e611fe7..33e4461 100644 Binary files a/Doku/Anleitung/Bilder/05-qualitaet.png and b/Doku/Anleitung/Bilder/05-qualitaet.png differ diff --git a/Doku/Anleitung/Bilder/06-terminplan.png b/Doku/Anleitung/Bilder/06-terminplan.png index 39b629c..23245ff 100644 Binary files a/Doku/Anleitung/Bilder/06-terminplan.png and b/Doku/Anleitung/Bilder/06-terminplan.png differ diff --git a/Doku/Anleitung/Bilder/07-diagramm-spieltage.png b/Doku/Anleitung/Bilder/07-diagramm-spieltage.png index 08b9159..6890db7 100644 Binary files a/Doku/Anleitung/Bilder/07-diagramm-spieltage.png and b/Doku/Anleitung/Bilder/07-diagramm-spieltage.png differ diff --git a/Doku/Anleitung/Bilder/10-einstellungen.png b/Doku/Anleitung/Bilder/10-einstellungen.png index 7f79a98..4baf2c4 100644 Binary files a/Doku/Anleitung/Bilder/10-einstellungen.png and b/Doku/Anleitung/Bilder/10-einstellungen.png differ diff --git a/Doku/Anleitung/Bilder/12-spielplandaten.png b/Doku/Anleitung/Bilder/12-spielplandaten.png index 560a0b0..d8a9947 100644 Binary files a/Doku/Anleitung/Bilder/12-spielplandaten.png and b/Doku/Anleitung/Bilder/12-spielplandaten.png differ diff --git a/Doku/Anleitung/Bilder/16-meldungen.png b/Doku/Anleitung/Bilder/16-meldungen.png index 4b1de65..ac1e4b6 100644 Binary files a/Doku/Anleitung/Bilder/16-meldungen.png and b/Doku/Anleitung/Bilder/16-meldungen.png differ diff --git a/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png b/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png index 57a74bc..f9610e5 100644 Binary files a/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png and b/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png differ diff --git a/Doku/Anleitung/Bilder/18-automodus.png b/Doku/Anleitung/Bilder/18-automodus.png index c5bcf28..ca9b9ad 100644 Binary files a/Doku/Anleitung/Bilder/18-automodus.png and b/Doku/Anleitung/Bilder/18-automodus.png differ diff --git a/Doku/Anleitung/Bilder/19-leitstand.png b/Doku/Anleitung/Bilder/19-leitstand.png index 5db0650..8f98c68 100644 Binary files a/Doku/Anleitung/Bilder/19-leitstand.png and b/Doku/Anleitung/Bilder/19-leitstand.png differ diff --git a/Doku/Anleitung/Bilder/20-leitstand-kosten.png b/Doku/Anleitung/Bilder/20-leitstand-kosten.png index 5694baa..fb2a416 100644 Binary files a/Doku/Anleitung/Bilder/20-leitstand-kosten.png and b/Doku/Anleitung/Bilder/20-leitstand-kosten.png differ diff --git a/Doku/Anleitung/Bilder/21-verlauf.png b/Doku/Anleitung/Bilder/21-verlauf.png index c4adc9f..79e3892 100644 Binary files a/Doku/Anleitung/Bilder/21-verlauf.png and b/Doku/Anleitung/Bilder/21-verlauf.png differ diff --git a/Doku/Anleitung/Bilder/22-sicherung.png b/Doku/Anleitung/Bilder/22-sicherung.png index 0b67db7..496bf8b 100644 Binary files a/Doku/Anleitung/Bilder/22-sicherung.png and b/Doku/Anleitung/Bilder/22-sicherung.png differ diff --git a/Doku/Anleitung/Bilder/26-daten-aus-clicktt.png b/Doku/Anleitung/Bilder/26-daten-aus-clicktt.png new file mode 100644 index 0000000..5c61296 Binary files /dev/null and b/Doku/Anleitung/Bilder/26-daten-aus-clicktt.png differ diff --git a/Doku/Anleitung/Bilder/27-terminwuensche-pdf.png b/Doku/Anleitung/Bilder/27-terminwuensche-pdf.png new file mode 100644 index 0000000..adb820f Binary files /dev/null and b/Doku/Anleitung/Bilder/27-terminwuensche-pdf.png differ diff --git a/Doku/Anleitung/Bilder/28-wochenspiele.png b/Doku/Anleitung/Bilder/28-wochenspiele.png new file mode 100644 index 0000000..fca4419 Binary files /dev/null and b/Doku/Anleitung/Bilder/28-wochenspiele.png differ diff --git a/Doku/Anleitung/Bilder/29-wochenende-entfernungen.png b/Doku/Anleitung/Bilder/29-wochenende-entfernungen.png new file mode 100644 index 0000000..3ce0432 Binary files /dev/null and b/Doku/Anleitung/Bilder/29-wochenende-entfernungen.png differ diff --git a/Doku/MIGRATIONSPLAN.md b/Doku/MIGRATIONSPLAN.md index edd421e..4911849 100644 --- a/Doku/MIGRATIONSPLAN.md +++ b/Doku/MIGRATIONSPLAN.md @@ -536,7 +536,7 @@ Funktional ist der Umfang des Originals portiert und um Automodus, Leitstand, ge - [ ] **Beta mit echten Staffelleitern (Phase 9):** zwei bis drei Staffeln parallel mit Original und neuem Programm planen, Befunde sammeln. - [x] **Sprache fest auf Deutsch:** *(erledigt 11.10.2026 in `Program.Main`; Prüfung auf englischem Windows steht noch aus)* `CultureInfo.DefaultThreadCurrentCulture`/`UICulture` beim Start auf de-DE setzen und auf einem englisch eingestellten Windows prüfen (Monatsnamen der Datumsauswahl, Zahlenformate, Standardtexte; vgl. Platzhalter „hour“/„minute“). - [x] **Änderungsprotokoll für Anwender:** *(erledigt 11.10.2026: `CHANGELOG.md`; offen: Abschnitt der Version automatisch als Release Notes ins GitHub-Release übernehmen)* `CHANGELOG.md` bzw. Release Notes je GitHub-Release, mit 1.0 eine Übersicht „Was ist anders als im Original“. -- [ ] **Testabdeckung der Oberfläche** neu messen (zuletzt 01.10.2026: gesamt 92,8 %, UI 76 %) und die neuen Abläufe absichern (Drag & Drop, Kalender, Sicherungsdialog, Einrichtung). +- [x] **Testabdeckung der Oberfläche** *(12.10.2026: `BedienungTests` – Bearbeitungsdialoge, Rückfrage beim Sichern, Ziehen in Setzliste und Wunschterminen, Leitstand und Verlauf während einer Generierung; UI 71,4 % → 85,3 %, gesamt 90,4 % → 93,6 %)* neu messen (zuletzt 01.10.2026: gesamt 92,8 %, UI 76 %) und die neuen Abläufe absichern (Drag & Drop, Kalender, Sicherungsdialog, Einrichtung). **Abrundung (auch nach 1.0 möglich)** @@ -639,6 +639,38 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa - **Ergebnis:** der beste Plan nach Stufen, die angepassten Gewichtungen und die Liste der Änderungen (`Inseloptimierer.Lenkung`). Spezial-Insel im Automodus aus. - *Beispiel (Verbandsoberliga, 3 min):* A 18 → 3, B 13 → 4 bei etwa gleich vielen C-Verstößen; Peter: „arbeitet sehr gut“. +## 12. Kompaktspieltage (Turnierrunden der Jugend, Stand 12.10.2026) + +**Name:** In Oberfläche und Anleitung heißt der Modus **Kompaktspieltage** (Peter, 12.10.2026: „Blockspieltage“ oder „Kompaktspieltage“ sind beide geläufig). Die Bezeichner im Code (`Turnier…`) und der Befehl `andigen turnier` bleiben vorerst. + +**Anlass:** Die Jugend-Verbandsoberliga spielt nur an Turniertagen (Halbrunde, 6–10 Mannschaften). Die Vereine melden über eine Umfrage (Google Formulare, CSV) je Sonntag „möglicher Heimspieltag“ und/oder „möglicher Auswärtsspieltag“ oder „können nicht“, dazu Halle, ob zwei Spiele parallel möglich sind, und Bemerkungen (z. B. Zeitwünsche). + +- **3er-Turnier:** 3 Mannschaften, Ausrichter – Gast 1, Gast 1 – Gast 2, Ausrichter – Gast 2 (10, 13, 16 Uhr). +- **4er-Turnier:** 4 Mannschaften in zwei Runden (11 und 14 Uhr): Ausrichter und eine zweite Mannschaft spielen je gegen die beiden übrigen. Die zweite „Heimmannschaft“ ist in Wahrheit Gast, ihr Spiel wird in click-TT in die Halle des Ausrichters verlegt. Die Halle muss zwei Spiele parallel erlauben. +- **Bedingungen:** jede Paarung genau einmal, höchstens ein Turnier je Mannschaft und Tag, Ausrichter kann zuhause, alle anderen auswärts. Es zählen nur die Meldungen der Umfrage, nicht die Sperrtermine und spielfreien Tage aus click-TT. **Vorrang** hat: jede Mannschaft richtet mindestens ein Turnier (mit zwei eigenen Begegnungen) aus – lieber ein Verstoß gegen eine Meldung als eine Mannschaft ohne Heimspieltag. +- **Wünsche als Freitext** (z. B. „erst ab 12 Uhr“, „Prio für Heimspieltag am 6.12.“): werden vorerst nur angezeigt. Besser als eine KI: feste Felder in der Umfrage (frühester Beginn, Wunschtermin für den Heimspieltag), der Freitext bleibt für Sonderfälle. +- **Rechnerische Grenze:** In beiden Formen spielt jede Mannschaft zwei Spiele je Turnier. Bei gerader Mannschaftszahl (6, 8, 10) hat jede Mannschaft eine ungerade Zahl von Gegnern – mit 3er und 4er allein geht der Plan dann nie ganz auf. Bisher bleibt dann für jede Mannschaft eine Paarung übrig, die als **Einzelbegegnung** mit möglichst kurzer Fahrt ausgetragen wird (Form `Einzel`; geprüft auf Heim/Auswärts, höchstens eine je Mannschaft, zählt nicht als ausgerichtetes Turnier). + +**Stufen:** + +- [x] **Prüfen** (`andigen turnier pruefen `): erkennt im Plan aus click-TT die Turniere (`Turniererkennung`), liest die Meldungen (`TurniermeldungenCsv`, repariert falsch kodierte Umlaute, letzte Meldung je Mannschaft gilt) und meldet alle Verstöße (`Turnierpruefung`); Bemerkungen werden zum Selbstprüfen ausgegeben. Für Turniere mit Befund schlägt die Prüfung passende Ausweichtage vor (Beispiel 2026: das Turnier in Trier-Olewig, nachträglich vom 08.11. auf den 01.11. verlegt). +- [ ] **Planen:** selbst einen Plan aus 3er- und 4er-Turnieren (und Einzelbegegnungen) suchen. Versuch 12.10.2026 (Plan 2026, nur 4er, exakte Überdeckung): über 120.000 gültige Pläne nach 5 Minuten, Suche nicht beendet – die Aufgabe ist also die Auswahl. Ziele nach Peter (12.10.2026): + - *Voraussetzungen:* Meldungen, Wunschtermine und Zeitwünsche, jede Paarung genau einmal, jede Mannschaft mindestens ein Heimspieltag (3er oder 4er). + - *1. Fahrtstrecken* möglichst kurz (Hallen brauchen Postleitzahl bzw. Adresse; zuerst Luftlinie, später Routenplaner). + - *2. Gleichmäßiger Stand:* zu jedem Zeitpunkt haben alle Mannschaften etwa gleich viele Spiele; keine ist fast fertig, während eine andere kaum angefangen hat. + - *3. Gemeinsamer Spieltag:* mindestens ein Tag, an dem möglichst alle Mannschaften spielen. + - *Abwägung im Programm statt fester Gewichte* (Peter, 12.10.2026): Der Planer legt mehrere gute Varianten vor (kürzeste Strecke, gleichmäßigster Stand, mit gemeinsamem Spieltag und Kompromisse dazwischen); Karte mit Hallen und Fahrten je Variante, km je Mannschaft. Fahrtwünsche vorgeben („Sinzig spielt dieses Jahr in Trier“), einzelne Turniere festhalten und den Rest neu rechnen. + - *Versuch 12.10.2026* (Luftlinie × 1,3, Plan 2026): Peters Plan ca. 5.570 km, kürzester gefundener Plan ca. 4.160 km – aber dauerhaft ein Turnier Abstand im Stand und kein gemeinsamer Spieltag. Die Kriterien ziehen gegeneinander. + - *Vergleich Bericht „Terminwünsche“ (PDF) mit der click-TT-XML* (Verbandsoberliga, 12.10.2026): Heimtermine mit Uhrzeit, Halle, max. Heimspiele (`maxParallelGames`), Heimkoppel (unbedingt/möglich = `coupleGame` hard/soft), Sperrtermine, Auswärtskoppeln mit Übernachtung (`onSameDay`), 60-km-Liste (`noWeekGame`) und Doppelspieltage (`noDoubleDays`) stehen auch in der XML. **Nur im PDF:** Adressen der Spiellokale 1–3, Bemerkung des Vereins (Freitext-Wünsche, bei 4 von 12 Mannschaften), Ball. Eine Abweichung: Bei SV Windhagen stehen Sperrtermine der Rückrunde nur in der (älteren) XML. + - *Region und Name* (Peter, 12.10.2026): Das Programm wird deutschlandweit eingesetzt – Karte und Entfernungen ohne Annahmen über eine Region, nur aus den Planungsdateien (Adressen der Spiellokale); Einschränkung höchstens auf Deutschland. Entfernungen sind für die Planung nur im Turniermodus nötig (der noch einen besseren Namen braucht); für normale Staffeln dienen sie der **Prüfung der 60-km-Regel** (nächster Schritt). + - *Zusätzliche Importe als erster Schritt der Einrichtung einer Staffel* (Peter, 12.10.2026): Nach der click-TT-XML fragt die Einrichtung nach weiteren Dateien – Bericht „Terminwünsche“ (PDF: Hallenadressen, Bemerkungen), „Mannschaftsaufstellungen“ (CSV: QTTR → Vorschlag für die Setzliste; Schnitt der besten 4 bzw. 6 Stammspieler ohne Reservespieler) und bei Turnierrunden die Umfrage (CSV). Gilt auch für die Jugend-Verbandsoberliga („Turniersystem“). + - *Hallen der Nachbarmannschaften* (umgesetzt 12.10.2026, Korrektur `HallenNachNamen`): Der Hallenname aus click-TT (`courtHall`) wird an Heimterminen und Nachbarspielen übernommen (Attribut `courthall`, nie in der Änderungsdatei); die Hallenbelegung zählt bei bekannten Hallen nur dieselbe Halle. Verbandsoberliga: bei Mülheim-Urmitz 10 von 19, bei Simmern 9 von 17 Heimspielen der Nachbarmannschaften in einer anderen Halle. + - *Änderungsprotokoll von click-TT* (geprüft 12.10.2026, **verworfen**): nur per Kopieren/Bildschirmfoto; Verlegungen auf Sperrterminen sind ohnehin klar, die übrigen haben kurzfristige Gründe (Hallenbelegung, Spieler verhindert, Nominierungen) – keine Information für die nächste Saison (Peter). + - *Hallenadressen:* aus dem click-TT-Bericht „Terminwünsche“ (PDF, nu.Dokument 019; Zuordnung über die Vereinsnummer, Halle aus der Umfrage). Nebennutzen für normale Staffeln: Entfernungen könnten die 60-km-Regel vorschlagen. + - *Prüfung der 60-km-Regel* (umgesetzt 12.10.2026): Die Wettspielordnung des RTTVR kennt **kein** Maß; die Liste „gesperrte Auswärtsspiele unter der Woche“ (`noWeekGame`) ist ein Vereinswunsch (§ 5.2: Sa/So verbindlich, je nach Ebene auch Freitag bzw. Mo–Fr 19:30–20:30). In der Verbandsoberliga sperren 5 von 12 Mannschaften pauschal alle Gegner. Deshalb nur **Hinweise**: Luftlinie zwischen den Hallen (Postleitzahl und Ort → Koordinaten aus GeoNames, CC BY 4.0, eingebettet als `plz-de.csv.gz`, erzeugt mit `tools/geonames/plz_tabelle.py`; deutschlandweit, ohne Netz), Grenze einstellbar (Vorgabe 60 km), geprüft nur bei Gastgebern mit Heimterminen unter der Woche **und** am Wochenende (Peter, 12.10.2026: nur dann entscheidet die Sperre; unter der Woche = Mo–Do, Freitag nur, wenn er laut Einstellung `friday-is-part-of-weekend` nicht zum Wochenende zählt; `Datenbearbeitung.FreitagZaehltZumWochenende` aus den Optionen der Sitzung). Ohne solche Gastgeber (z. B. Verbandsoberliga: nur Sa/So) sagt die Seite ausdrücklich, dass es nichts zu prüfen gibt. **Fehlende Sperren setzen…** (Peter, 12.10.2026): eine Rückfrage für alle Treffer, setzt nur fehlende (`WochenspielSperrenSetzen`), Protokoll auf der Seite (Zeitpunkt, Grenze, je Sperre Gast/Gastgeber/km); „Standard“ nimmt sie zurück (`WochenspielSperrenEntfernen`). Unnötige Sperren werden nicht automatisch entfernt. Adresszeilen mit Anhang nach „PLZ Ort“ (SV Windhagen: „…, 53578 Windhagen, Fax: GPS:50.63599°N 7.35227 °E“) wurden zunächst nicht erkannt; jetzt zählt der letzte Teil „PLZ Ort“, eine GPS-Angabe wird als genauere Lage übernommen (`Spiellokaladresse.Lage`). „Sperre fehlt vermutlich“ (weiter, keine Sperre) und „Sperre vermutlich unnötig“ (Sperre, höchstens so weit); wer alle Gegner sperrt, wird nicht bewertet. Anzeige auf der Einrichtungsseite „Daten aus click-TT“ und je Gegner auf der Seite „Wochenende (60-km-Regel)“ (Entfernung und Marke „weit“/„nah“). Spiellokal der Mannschaft: Nummer an der Mannschaft, sonst die häufigste der Heimtermine, sonst 1. + - *Einbindung:* Eingabe in den Spielplandaten (CSV-Import der Umfrage auf Sperrtermine und Heimspieltermine), eigener Turnierplaner statt des Optimierers, Ergebnis als normaler Plan (Ansichten, Druck, click-TT-CSV). +- [ ] **Oberfläche:** eigene Seite, sobald Prüfen und Planen tragen. + ## Änderungsprotokoll | Datum | Änderung | |---|---| @@ -805,3 +837,9 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa | 11.10.2026 | Klasse-3-Befunde entschieden (E9): #3, #4, #5, #6 werden korrigiert (`Domain.Optionen.Korrekturen`, Standard `Alle`, in Kern- und Referenzmodell gleich, damit die Differenztests greifen), #33 als Einstellung „Bei ungerader Mannschaftszahl darf am Pflichtspieltag eine Mannschaft spielfrei sein“ (Standard an). `Berechnungsoptionen.Original` rechnet wie das Original (Paritätstests); `andigen optimieren --original` zum Vergleich. Mit der Regel hängt der Kostenwert Pflichtspieltage jeder Mannschaft von allen ab; der Cache rechnet diese Kostenart dann bei jeder Änderung für alle Mannschaften | | 11.10.2026 | Messung der Korrekturen (`korrekturvergleich.cmd`: drei Staffeln mit ungerader Mannschaftszahl, je Variante 2 × 120 s Kostenoptimierung aus leerem Plan, alle Pläne in beiden Kostenmodellen bewertet; Mittel im neuen Kostenmodell). **R3** (9 Mannschaften, Pflichtspieltage): neu 50 T, Original 98 T – das Original weicht der Pflichtspieltag-Strafe aus, indem eine Mannschaft zwei Spiele in einer Woche bekommt (je 40 T), mit der Regel spielt sie stattdessen in der Woche davor. **R19** (11): neu 481 T, Original 532 T. **3. Kreisklasse** (13): neu 438 T, Original 377 T. Die Streuung innerhalb einer Variante (z. B. 524 T / 352 T) ist größer als die Unterschiede; für die Suchkorrekturen #3, #5, #6 ist damit weder ein Vorteil noch ein Nachteil belegt. Die summierte Überlappung des letzten Spieltags (#4) änderte bei keinem der zwölf Pläne den Wert. Geschwindigkeit: bei R3 mit der Pflichtspieltag-Regel rund 3 % weniger Pläne/s (Kostenart wird für alle Mannschaften neu gerechnet), sonst gleich | | 11.10.2026 | E12 Signierung: SignPath Foundation hat abgelehnt; 1.0 erscheint unsigniert (kostenfrei), `CODE_SIGNING.md` angepasst. Release-Workflow übernimmt den Abschnitt der Version aus `CHANGELOG.md` als Release Notes | +| 12.10.2026 | Testabdeckung: neue Oberflächentests `BedienungTests` (fünf Abläufe mit Maus und laufender Generierung). Abdeckung UI 85,3 %, gesamt 93,6 %. Beim Ziehen eines Wunschtermins setzt das Anfassen den Fokus und kann den Bildlauf verschieben; der Test bestimmt den Zieltag deshalb nach dem Anfassen neu | +| 12.10.2026 | Download vereinfacht (Befund Peter: zu viele Dateien im Release): Der Release-Text beginnt mit einem Wegweiser „Welche Datei brauche ich?“ (je System eine Datei), README und Anleitung haben dieselbe Tabelle mit Links `releases/latest/download/…`. Dafür heißen die Mac- und Linux-Pakete ohne Versionsnummer (`AndiGeneratorNET-macos-arm64.dmg`, `andigenerator-net_amd64.deb`, `AndiGeneratorNET-linux-x64.tar.gz` …); die Windows-Dateien waren schon versionsfrei. Die festen Links auf Mac und Linux greifen ab dem nächsten Release | +| 12.10.2026 | Abschnitt 12 Turnierrunden (Jugend): Stufe Prüfen umgesetzt (`andigen turnier pruefen`) | +| 12.10.2026 | Einrichtung: erster Punkt „Daten aus click-TT ergänzen“ (Mannschaftsmeldung → Vorschlag für die Setzliste; Vermerk `mannschaftsmeldung` am Knoten `plan`, danach als erledigt markiert); **Abbrechen** schließt die Staffel; Öffnen und Neu sind während der Einrichtung gesperrt (ersetzt die Regel vom 30.09.2026) | +| 12.10.2026 | Bericht „Terminwünsche“ (PDF) in der Einrichtung: neuer Baustein `AndiGenerator.Import` mit PdfPig (Apache-2.0, E11), Auswertung der Textzeilen in `Persistence.Terminwuensche`; setzt Spiellokal-Nummern aus dem Hallennamen, merkt Adressen (`spiellokal1`–`3`) und Bemerkung (`bemerkung`) am Knoten `team`, zeigt Unterschiede. Prüfung an 8 echten Berichten: Termine, Hallen, max. Heimspiele, Heimkoppel und Sperren stimmen mit der XML überein | +| 12.10.2026 | Prüfung der 60-km-Regel (`Datenbearbeitung.WochenspielePruefen`, `Hallenentfernungen`; `Koordinate`, `Postleitzahlen` mit GeoNames-Tabelle): Hinweise auf der Seite „Daten aus click-TT“, Entfernungen und Marken auf der Seite „Wochenende (60-km-Regel)“ (siehe Abschnitt 12). Turniermodus heißt in Oberfläche und Anleitung **Kompaktspieltage**. | diff --git a/README.md b/README.md index ccf31b7..e9a3937 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,18 @@ AndiGenerator.NET ist die C#/.NET-Portierung des **AndiGenerators** von **Andrea ## Installation -Die neueste Version gibt es unter [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases): `AndiGeneratorNET-win-Setup.exe` installiert das Programm für den eigenen Benutzer (ohne Administratorrechte) und hält es danach selbst aktuell; `AndiGeneratorNET-win-Portable.zip` läuft ohne Installation. Windows 10/11, 64 Bit; für Windows-Laptops mit ARM-Prozessor (Snapdragon) `AndiGeneratorNET-win-arm64-Setup.exe` bzw. `-win-arm64-Portable.zip`. Für Macs (macOS 14 oder neuer) gibt es Disk-Images `AndiGeneratorNET--macos-arm64.dmg` (Apple-Chip) bzw. `-macos-x64.dmg` (Intel). Für Chromebooks (Linux-Entwicklungsumgebung) und Linux gibt es Debian-Pakete `andigenerator-net__amd64.deb` bzw. `_arm64.deb` und portable `tar.gz`. Bis zur digitalen Signierung warnt Windows beim ersten Start – Einzelheiten in der [Anleitung](Doku/ANLEITUNG.md#2-installation). +Die neueste Version – ein Klick lädt die passende Datei: + +| Ihr Rechner | Datei | +|---|---| +| Windows-PC oder -Laptop (Intel/AMD – der Normalfall) | [AndiGeneratorNET-win-Setup.exe](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-win-Setup.exe) | +| Windows-Laptop mit ARM-Prozessor (z. B. Snapdragon) | [AndiGeneratorNET-win-arm64-Setup.exe](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-win-arm64-Setup.exe) | +| Mac mit Apple-Chip (M1 und neuer) | [AndiGeneratorNET-macos-arm64.dmg](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-macos-arm64.dmg) | +| Mac mit Intel-Prozessor | [AndiGeneratorNET-macos-x64.dmg](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/AndiGeneratorNET-macos-x64.dmg) | +| Chromebook oder Linux mit Intel/AMD | [andigenerator-net_amd64.deb](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/andigenerator-net_amd64.deb) | +| Chromebook oder Linux mit ARM-Prozessor | [andigenerator-net_arm64.deb](https://github.com/PBuchmann/AndiGenerator.NET/releases/latest/download/andigenerator-net_arm64.deb) | + +Das Windows-Setup installiert ohne Administratorrechte und hält das Programm danach selbst aktuell. Weitere Dateien (ohne Installation, ältere Versionen) gibt es unter [Releases](https://github.com/PBuchmann/AndiGenerator.NET/releases). Bis zur digitalen Signierung warnt Windows beim ersten Start – Einzelheiten in der [Anleitung](Doku/ANLEITUNG.md#2-installation). ## Selbst bauen diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index ce05121..381e8b0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -4,6 +4,8 @@ Für alles, was mit dem Programm ausgeliefert oder im Quellcode als Bibliothek v Ausnahme Schriften: Die SIL Open Font License (OFL 1.1) erlaubt das Einbetten und Weitergeben mit GPL-Software; der Lizenztext liegt neben den Schriftdateien, der reservierte Name „Plex“ wird nicht für veränderte Schriften verwendet. +Ausnahme Daten: Die Postleitzahlen-Tabelle `src/AndiGenerator.Persistence/Geo/plz-de.csv.gz` (Postleitzahl, Ort, Breite, Länge) ist aus `DE.zip` von **GeoNames** (https://www.geonames.org, Postleitzahlen-Export https://download.geonames.org/export/zip/) abgeleitet: auf diese vier Spalten gekürzt und komprimiert (`tools/geonames/plz_tabelle.py`). Die Daten stehen unter **Creative Commons Attribution 4.0** (CC BY 4.0, https://creativecommons.org/licenses/by/4.0/); Namensnennung: „Postleitzahlen-Daten: GeoNames (geonames.org), CC BY 4.0, gekürzt“. Sie dienen nur der Entfernung in Luftlinie zwischen Hallen (Prüfung der 60-km-Regel) und sind keine Programmbibliothek. + | Paket | Lizenz | Verwendung | |---|---|---| | .NET 10 Runtime/BCL | MIT | Laufzeit | @@ -11,6 +13,7 @@ Ausnahme Schriften: Die SIL Open Font License (OFL 1.1) erlaubt das Einbetten un | Avalonia.Skia (mit SkiaSharp, HarfBuzzSharp) | MIT | PDF-Ausdruck (`SKDocument`, Phase 7) | | Dock.Avalonia, Dock.Avalonia.Themes.Fluent, Dock.Model.Mvvm | MIT | Andock-Layout der Ansichten (E15) | | CommunityToolkit.Mvvm | MIT | ViewModels (E3) | +| PdfPig (UglyToad.PdfPig) | Apache-2.0 | Bericht „Terminwünsche“ aus click-TT lesen (nur im Baustein `AndiGenerator.Import`) | | Velopack | MIT | Installation und Updates über GitHub Releases (E12); Version gemeinsam mit `VPK_VERSION` in `release.yml` | | Schriften IBM Plex Sans und IBM Plex Mono (je Regular, Medium, SemiBold, Bold), © 2017 IBM Corp. | SIL OFL 1.1 | Schrift der Oberfläche, eingebettet unter `src/AndiGenerator.UI/Assets/Fonts` mit `OFL.txt` | | xunit | Apache-2.0 | nur Tests | diff --git a/hallenvergleich.cmd b/hallenvergleich.cmd new file mode 100644 index 0000000..c7ef51e --- /dev/null +++ b/hallenvergleich.cmd @@ -0,0 +1,30 @@ +@echo off +rem Vergleich der Korrektur "Hallen nach Namen" mit dem Original-Verhalten (alle anderen Korrekturen bleiben an): +rem Verbandsoberliga, je Variante drei reproduzierbare Laeufe (1 Kern, feste Startwerte, 2 Mio. Durchlaeufe) aus +rem leerem Plan (zusammen etwa 6-10 Minuten). Danach werden alle Plaene in beiden Kostenmodellen bewertet. +rem Ergebnisse im Ordner hallenvergleich (wird von Claude gelesen). Vorher bauen.cmd ausfuehren. +chcp 65001 >nul +cd /d "%~dp0" +set "EXE=%LOCALAPPDATA%\AndiGenerator.NET\artifacts\bin\AndiGenerator.Cli\release\andigen.exe" +set "ZIEL=%~dp0hallenvergleich" +set "XML=%~dp0Referenz\eingabe\R21_Verbandsoberliga (1).xml" +if not exist "%EXE%" ( + echo andigen.exe nicht gefunden - bitte zuerst bauen.cmd ausfuehren. + pause + exit /b 1 +) +if not exist "%ZIEL%" mkdir "%ZIEL%" +for %%s in (1 2 3) do ( + echo. + echo ======== Hallen nach Namen, Startwert %%s ======== + "%EXE%" optimieren "%XML%" --leer --kerne 1 --startwert %%s --durchlaeufe 2000000 --intervall 30 --plan "%ZIEL%\namen-%%s.xml" --ausgabe "%ZIEL%\namen-%%s.txt" + echo. + echo ======== Hallen wie im Original, Startwert %%s ======== + "%EXE%" optimieren "%XML%" --leer --kerne 1 --startwert %%s --durchlaeufe 2000000 --intervall 30 --hallen-wie-original --plan "%ZIEL%\original-%%s.xml" --ausgabe "%ZIEL%\original-%%s.txt" +) +"%EXE%" bewerten "%XML%" "%ZIEL%\namen-1.xml" "%ZIEL%\namen-2.xml" "%ZIEL%\namen-3.xml" "%ZIEL%\original-1.xml" "%ZIEL%\original-2.xml" "%ZIEL%\original-3.xml" --details --ausgabe "%ZIEL%\bewertung-namen.txt" +"%EXE%" bewerten "%XML%" "%ZIEL%\namen-1.xml" "%ZIEL%\namen-2.xml" "%ZIEL%\namen-3.xml" "%ZIEL%\original-1.xml" "%ZIEL%\original-2.xml" "%ZIEL%\original-3.xml" --hallen-wie-original --details --ausgabe "%ZIEL%\bewertung-original.txt" +echo. +echo Fertig. Ergebnisse im Ordner hallenvergleich - Claude kann sie jetzt lesen. +echo ===== ENDE %DATE% %TIME% > "%ZIEL%\fertig.txt" +pause diff --git a/pdftext.cmd b/pdftext.cmd new file mode 100644 index 0000000..87f7fa9 --- /dev/null +++ b/pdftext.cmd @@ -0,0 +1,8 @@ +@echo off +rem Zeigt die Textzeilen eines PDF-Berichts, wie der Import sie liest (Fehlersuche). Ausgabe nach pdftext-log.txt. +chcp 65001 >nul +set "EXE=%LOCALAPPDATA%\AndiGenerator.NET\artifacts\bin\AndiGenerator.Cli\release\andigen.exe" +set "PDF=%~1" +if "%PDF%"=="" set "PDF=%~dp0Referenz\terminwuensche\Terminmeldung_Bezirksoberliga Rheinhessen Nord.pdf" +"%EXE%" pdftext "%PDF%" "%~dp0pdftext-log.txt" +pause diff --git a/src/AndiGenerator.Application/AndiGenerator.Application.csproj b/src/AndiGenerator.Application/AndiGenerator.Application.csproj index b5ada5c..13b0d98 100644 --- a/src/AndiGenerator.Application/AndiGenerator.Application.csproj +++ b/src/AndiGenerator.Application/AndiGenerator.Application.csproj @@ -6,6 +6,7 @@ + diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Heimtage.cs b/src/AndiGenerator.Application/Datenbearbeitung.Heimtage.cs index c39b127..ec2ee71 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.Heimtage.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.Heimtage.cs @@ -31,11 +31,16 @@ public void HeimtageSpeichern(string mannschaft, IEnumerable tage) List liste = tage.ToList(); foreach (DatenKnoten team in Teams(Arbeitsstand).Where(t => t.Lesen("teamname") == mannschaft)) { + // Der Hallenname aus click-TT bleibt erhalten, solange Tag und Spiellokal gleich bleiben. + Dictionary<(DateOnly Tag, string Lokal), string> hallen = team.KinderMitNamen("homegameday") + .Where(k => k.Lesen(StaffelAbbildung.HallenAttribut).Length > 0) + .GroupBy(k => (DateOnly.FromDateTime(DelphiKompatibel.DatumZeitStreng(k.Lesen("datetime")) ?? DelphiKompatibel.DelphiNull), Spiellokale.Korrigieren(k.Lesen("location")))) + .ToDictionary(g => g.Key, g => g.First().Lesen(StaffelAbbildung.HallenAttribut)); team.KinderEntfernen("homegameday"); team.KinderEntfernen("nogameday"); foreach (Heimtag h in liste) { - HeimtagSchreiben(team, h); + HeimtagSchreiben(team, h, hallen.GetValueOrDefault((DateOnly.FromDateTime(h.Datum), Spiellokale.Korrigieren(h.Spiellokal)))); } } } @@ -81,7 +86,7 @@ private static Heimtag HeimtagLesen(DatenKnoten knoten) Spiellokale.Korrigieren(knoten.Lesen("location"))); } - private static void HeimtagSchreiben(DatenKnoten team, Heimtag h) + private static void HeimtagSchreiben(DatenKnoten team, Heimtag h, string? halle) { if (h.Sperrtermin) { @@ -94,6 +99,11 @@ private static void HeimtagSchreiben(DatenKnoten team, Heimtag h) knoten.Setzen("parallelgames", h.MaxParallel); knoten.Setzen("ausweichtermin", h.Ausweichtermin); knoten.Setzen("location", Spiellokale.Korrigieren(h.Spiellokal)); + if (!string.IsNullOrEmpty(halle)) + { + knoten.Setzen(StaffelAbbildung.HallenAttribut, halle); + } + knoten.Setzen("doublegameday", h.Doppeltermin); knoten.Setzen("couplegameday", h.Koppeltermin); knoten.Setzen("coupleauswaertssecondtime", h.AuswaertsKoppelZweitzeit); diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs b/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs index b75009a..cc08552 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; +using AndiGenerator.Persistence.Aufstellungen; using AndiGenerator.Persistence.Plandaten; namespace AndiGenerator.Application; @@ -9,6 +11,17 @@ namespace AndiGenerator.Application; /// Seite „Setzliste“ (Original TDialogPanelRanking). public sealed partial class Datenbearbeitung { + /// Attribut am Knoten plan: wann welche Mannschaftsmeldung übernommen wurde (nicht im Original). + private const string MannschaftsmeldungAttribut = "mannschaftsmeldung"; + + /// Holt den Vermerk, welche Mannschaftsmeldung wann übernommen wurde, oder einen leeren Text. + public string MannschaftsmeldungVermerk => Arbeitsstand.Lesen(MannschaftsmeldungAttribut); + + /// Vermerkt, dass eine Mannschaftsmeldung übernommen wurde (für die Einrichtung: „schon erledigt“). + /// Name der Datei. + public void MannschaftsmeldungVermerken(string datei) => + Arbeitsstand.Setzen(MannschaftsmeldungAttribut, $"„{datei}“ übernommen am {DateTime.Today.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"); + /// /// Liest die Setzliste wie das Original (DataToForm): zuerst die gespeicherte Reihenfolge (nur vorhandene /// Mannschaften), dann alle übrigen Mannschaften alphabetisch. @@ -43,6 +56,37 @@ public Setzlistendaten Setzliste() return new Setzlistendaten(aktiv, liste); } + /// Liest die Mannschaftsmeldung aus click-TT (CSV) und schlägt daraus die Setzliste vor. + /// Pfad der CSV-Datei. + /// Der Vorschlag. + /// Die Datei ist keine Mannschaftsmeldung. + /// Die Datei kann nicht gelesen werden. + public Setzlistenvorschlag SetzlisteAusMannschaftsmeldung(string pfad) => SetzlisteVorschlagen(MannschaftsaufstellungenCsv.Laden(pfad)); + + /// + /// Vorschlag für die Setzliste aus den Mannschaftsaufstellungen von click-TT: je Mannschaft der Schnitt der besten + /// Stammspieler (bei Sechser-Mannschaften 6, sonst 4 – erkannt an der üblichen Zahl gemeldeter Stammspieler), die + /// stärkste Mannschaft zuerst. Mannschaften ohne Aufstellung kommen in ihrer bisherigen Reihenfolge ans Ende. + /// + /// Die Aufstellungen aus . + /// Der Vorschlag. + public Setzlistenvorschlag SetzlisteVorschlagen(IReadOnlyList aufstellungen) + { + ArgumentNullException.ThrowIfNull(aufstellungen); + IReadOnlyList bisher = Setzliste().Reihenfolge; + var zugeordnet = bisher.ToDictionary( + n => n, + n => aufstellungen.FirstOrDefault(a => a.Mannschaft == n) ?? aufstellungen.FirstOrDefault(a => Vergleichsform(a.Mannschaft) == Vergleichsform(n)), + StringComparer.Ordinal); + List anzahlen = zugeordnet.Values.OfType().Select(a => a.Stammspieler.Count).Where(n => n > 0).Order().ToList(); + int gewertet = anzahlen.Count > 0 && anzahlen[anzahlen.Count / 2] >= 6 ? 6 : 4; + List staerken = bisher + .Select(n => zugeordnet[n] is Mannschaftsaufstellung a ? new Mannschaftsstaerke(n, a.Schnitt(gewertet), a.Stammspieler.Count) : new Mannschaftsstaerke(n, 0.0, 0)) + .ToList(); + List reihenfolge = [.. staerken.Where(s => s.HatAufstellung).OrderByDescending(s => s.Schnitt), .. staerken.Where(s => !s.HatAufstellung)]; + return new Setzlistenvorschlag(reihenfolge, gewertet); + } + /// Schreibt die Setzliste wie das Original (FormToData): der Knoten wird neu aufgebaut. /// Die Setzliste. public void SetzlisteSpeichern(Setzlistendaten daten) @@ -59,4 +103,6 @@ public void SetzlisteSpeichern(Setzlistendaten daten) team.Setzen("rankingindex", i); } } + + private static string Vergleichsform(string name) => string.Concat(name.Where(char.IsLetterOrDigit)).ToUpperInvariant(); } diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Terminwuensche.cs b/src/AndiGenerator.Application/Datenbearbeitung.Terminwuensche.cs new file mode 100644 index 0000000..7c2738b --- /dev/null +++ b/src/AndiGenerator.Application/Datenbearbeitung.Terminwuensche.cs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Import; +using AndiGenerator.Persistence.Gemeinsam; +using AndiGenerator.Persistence.Plandaten; +using AndiGenerator.Persistence.Terminwuensche; + +namespace AndiGenerator.Application; + +/// +/// Bericht „Terminwünsche“ aus click-TT (PDF, nicht im Original): Spiellokal-Nummern aus den Hallennamen, Adressen der +/// Spiellokale und Bemerkungen der Vereine übernehmen; Unterschiede zu den Spielplandaten zeigen. +/// +public sealed partial class Datenbearbeitung +{ + /// Attribut am Knoten plan: wann welcher Bericht übernommen wurde (nicht im Original). + private const string TerminwunschAttribut = "terminwuensche"; + + /// Attribut am Knoten team: Bemerkung des Vereins aus dem Bericht (nicht im Original). + private const string BemerkungAttribut = "bemerkung"; + + /// Attribute am Knoten team: Adressen der Spiellokale 1 bis 3 (nicht im Original). + private const string AdressAttribut = "spiellokal"; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + /// Holt den Vermerk, welcher Bericht „Terminwünsche“ wann übernommen wurde, oder einen leeren Text. + public string TerminwunschVermerk => Arbeitsstand.Lesen(TerminwunschAttribut); + + /// Liest den Bericht „Terminwünsche“ (PDF) und gleicht ihn mit den Spielplandaten ab, ohne etwas zu ändern. + /// Pfad der PDF-Datei. + /// Der Abgleich. + /// Die Datei ist kein Bericht „Terminwünsche“. + /// Die Datei kann nicht gelesen werden. + public Terminwunschabgleich TerminwuenscheAbgleichen(string pfad) => TerminwuenscheAbgleichen(TerminwunschPdf.Laden(pfad)); + + /// Gleicht gelesene Meldungen mit den Spielplandaten ab, ohne etwas zu ändern. + /// Die Meldungen. + /// Der Abgleich. + public Terminwunschabgleich TerminwuenscheAbgleichen(IReadOnlyList meldungen) + { + ArgumentNullException.ThrowIfNull(meldungen); + var mannschaften = new List(); + foreach (DatenKnoten team in Teams(Arbeitsstand)) + { + string name = team.Lesen("teamname"); + if (MeldungZu(team, meldungen) is not Vereinsmeldung meldung) + { + mannschaften.Add(new Mannschaftsabgleich(name, false, [], 0, string.Empty, [])); + continue; + } + + mannschaften.Add(new Mannschaftsabgleich( + name, + true, + meldung.Spiellokale.OrderBy(l => l.Key).Select(l => new Spiellokalangabe(l.Key, l.Value.Name, Adresse(l.Value))).ToList(), + Zuordnungen(team, meldung).Count, + meldung.Bemerkung, + Abweichungen(team, meldung))); + } + + return new Terminwunschabgleich(meldungen, mannschaften); + } + + /// + /// Übernimmt den Bericht: setzt bei Heimterminen ohne Spiellokal die Nummer des Spiellokals mit gleichem Hallennamen und + /// merkt sich Adressen und Bemerkungen an der Mannschaft. Termine und Sperren bleiben, wie sie sind. + /// + /// Der Abgleich. + /// Name der Datei für den Vermerk. + public void TerminwuenscheUebernehmen(Terminwunschabgleich abgleich, string datei) + { + ArgumentNullException.ThrowIfNull(abgleich); + foreach (DatenKnoten team in Teams(Arbeitsstand)) + { + if (MeldungZu(team, abgleich.Meldungen) is not Vereinsmeldung meldung) + { + continue; + } + + foreach ((DatenKnoten termin, int nummer) in Zuordnungen(team, meldung)) + { + termin.Setzen("location", nummer.ToString(CultureInfo.InvariantCulture)); + } + + foreach ((int nummer, Spiellokaladresse adresse) in meldung.Spiellokale) + { + team.Setzen(AdressAttribut + nummer.ToString(CultureInfo.InvariantCulture), adresse.Text); + } + + team.Setzen(BemerkungAttribut, meldung.Bemerkung); + } + + Arbeitsstand.Setzen(TerminwunschAttribut, $"„{datei}“ übernommen am {DateTime.Today.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"); + } + + private static Vereinsmeldung? MeldungZu(DatenKnoten team, IReadOnlyList meldungen) + { + int verein = DelphiKompatibel.ZahlLesen(team.Lesen("clubid"), -1); + int nummer = Math.Max(1, team.LesenZahl("teamnumber")); + return meldungen.FirstOrDefault(m => m.VereinsNummer == verein && m.MannschaftsNummer == nummer); + } + + /// Heimtermine ohne Spiellokal, deren Halle (aus click-TT) einem Spiellokal des Berichts entspricht. + private static List<(DatenKnoten Termin, int Nummer)> Zuordnungen(DatenKnoten team, Vereinsmeldung meldung) + { + var ergebnis = new List<(DatenKnoten Termin, int Nummer)>(); + foreach (DatenKnoten termin in team.KinderMitNamen("homegameday").Where(t => t.Lesen("location").Length == 0)) + { + string halle = Hallenform(termin.Lesen(StaffelAbbildung.HallenAttribut)); + int nummer = halle.Length == 0 ? 0 : meldung.Spiellokale.FirstOrDefault(l => Hallenform(l.Value.Name) == halle).Key; + if (nummer > 0) + { + ergebnis.Add((termin, nummer)); + } + } + + return ergebnis; + } + + /// Heim- und Sperrtermine, die nur im Bericht oder nur in den Spielplandaten stehen. + private static List Abweichungen(DatenKnoten team, Vereinsmeldung meldung) + { + var daten = team.KinderMitNamen("homegameday") + .Select(k => DelphiKompatibel.DatumZeitStreng(k.Lesen("datetime"))) + .OfType() + .ToHashSet(); + var bericht = meldung.Heimtermine.Select(t => t.Zeitpunkt).ToHashSet(); + var sperrenDaten = team.KinderMitNamen("nogameday").Select(k => Datum(k.Lesen("date"))).ToHashSet(); + var sperrenBericht = meldung.Sperrtermine.ToHashSet(); + + var ergebnis = new List(); + ergebnis.AddRange(bericht.Except(daten).Order().Select(t => "Heimtermin nur im Bericht: " + Termintext(t))); + ergebnis.AddRange(daten.Except(bericht).Order().Select(t => "Heimtermin nur in den Spielplandaten: " + Termintext(t))); + ergebnis.AddRange(sperrenBericht.Except(sperrenDaten).Order().Select(t => "Sperrtermin nur im Bericht: " + Tagtext(t))); + ergebnis.AddRange(sperrenDaten.Except(sperrenBericht).Order().Select(t => "Sperrtermin nur in den Spielplandaten: " + Tagtext(t))); + return ergebnis; + } + + private static string Adresse(Spiellokaladresse a) => + string.Join(", ", new[] { a.Strasse, (a.Postleitzahl + " " + a.Ort).Trim() }.Where(t => t.Length > 0)); + + private static string Hallenform(string halle) => + string.Join(' ', halle.Replace("- ", "-", StringComparison.Ordinal).Split(' ', StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant(); + + private static string Termintext(DateTime zeit) => zeit.ToString("ddd dd.MM.yyyy HH:mm", Deutsch); + + private static string Tagtext(DateOnly tag) => tag.ToString("ddd dd.MM.yyyy", Deutsch); +} diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs b/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs new file mode 100644 index 0000000..e2f411d --- /dev/null +++ b/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Domain.Geo; +using AndiGenerator.Persistence.Gemeinsam; +using AndiGenerator.Persistence.Geo; +using AndiGenerator.Persistence.Plandaten; +using AndiGenerator.Persistence.Terminwuensche; + +namespace AndiGenerator.Application; + +/// +/// Prüfung der Liste „keine Auswärtsspiele unter der Woche“ (60-km-Regel, nicht im Original): Die Wettspielordnung legt +/// kein Maß fest, die Liste ist ein Wunsch der Vereine. Die Prüfung gibt deshalb nur Hinweise – nach der Luftlinie +/// zwischen den Hallen (aus Postleitzahl und Ort) und nur für Gastgeber mit Heimterminen unter der Woche. +/// +public sealed partial class Datenbearbeitung +{ + /// Vorgabe für die Grenze in Kilometern Luftlinie. + public const double Wochenspielgrenze = 60; + + /// Prüft mit den gespeicherten Hallenadressen (aus einem übernommenen Bericht „Terminwünsche“). + /// Grenze in Kilometern Luftlinie, z. B. 60. + /// Das Ergebnis. + public Wochenspielpruefung WochenspielePruefen(double grenze) => WochenspielePruefen(grenze, []); + + /// Prüft; die Adressen kommen aus den Meldungen, sonst aus den gespeicherten Hallenadressen. + /// Grenze in Kilometern Luftlinie, z. B. 60. + /// Meldungen eines gerade geladenen Berichts „Terminwünsche“ (darf leer sein). + /// Das Ergebnis. + public Wochenspielpruefung WochenspielePruefen(double grenze, IReadOnlyList meldungen) + { + ArgumentNullException.ThrowIfNull(meldungen); + List namen = Teams(Arbeitsstand).Select(t => t.Lesen("teamname")).ToList(); + Dictionary lage = HallenLagen(meldungen); + List ohneLage = namen.Where(n => !lage.ContainsKey(n)).ToList(); + var gesperrt = namen.ToDictionary(n => n, n => NurWochenendeAus(Arbeitsstand, n).ToHashSet(StringComparer.OrdinalIgnoreCase), StringComparer.Ordinal); + List pauschal = namen.Where(n => namen.Count > 2 && namen.All(g => g == n || gesperrt[n].Contains(g))).ToList(); + HashSet wochentags = Wochentagsgastgeber(); + + var befunde = new List(); + int geprueft = 0; + foreach (string gast in namen.Where(lage.ContainsKey)) + { + foreach (string heim in namen.Where(h => h != gast && wochentags.Contains(h) && lage.ContainsKey(h))) + { + geprueft++; + double km = lage[gast].Luftlinie(lage[heim]); + bool gesperrtGegen = gesperrt[gast].Contains(heim); + if (!gesperrtGegen && km > grenze) + { + befunde.Add(new Wochenspielbefund(gast, heim, km, Wochenspielhinweis.SperreFehlt)); + } + else if (gesperrtGegen && km <= grenze && !pauschal.Contains(gast)) + { + befunde.Add(new Wochenspielbefund(gast, heim, km, Wochenspielhinweis.SperreUnnoetig)); + } + } + } + + return new Wochenspielpruefung( + grenze, + befunde.OrderBy(b => b.Art).ThenByDescending(b => b.Luftlinie).ToList(), + pauschal, + ohneLage, + namen.Where(wochentags.Contains).ToList(), + geprueft); + } + + /// + /// Entfernungen von der Halle einer Mannschaft zu den Hallen der anderen (aus den gespeicherten Hallenadressen) – für + /// die Seite „Wochenende (60-km-Regel)“. + /// + /// Mannschaftsname (der Gast). + /// Je anderer Mannschaft die Entfernung, in der Reihenfolge der Staffel. + public IReadOnlyList Hallenentfernungen(string mannschaft) + { + Dictionary lage = HallenLagen([]); + HashSet wochentags = Wochentagsgastgeber(); + bool eigene = lage.TryGetValue(mannschaft, out Koordinate start); + return Teams(Arbeitsstand) + .Select(t => t.Lesen("teamname")) + .Where(n => n != mannschaft) + .Select(n => new Hallenentfernung( + n, + eigene && lage.TryGetValue(n, out Koordinate ziel) ? start.Luftlinie(ziel) : null, + wochentags.Contains(n))) + .ToList(); + } + + /// + /// Setzt die Sperren der Befunde „Sperre fehlt vermutlich“: Der Gast spielt beim Gastgeber nur noch am Wochenende. + /// Vorhandene Sperren bleiben. + /// + /// Befunde der Prüfung; nur zählt. + /// Die tatsächlich gesetzten Sperren (für das Protokoll). + public IReadOnlyList WochenspielSperrenSetzen(IEnumerable befunde) + { + ArgumentNullException.ThrowIfNull(befunde); + var gesetzt = new List(); + foreach (IGrouping gast in befunde.Where(b => b.Art == Wochenspielhinweis.SperreFehlt).GroupBy(b => b.Gast, StringComparer.Ordinal)) + { + List gegner = NurWochenendeAus(Arbeitsstand, gast.Key); + List neu = gast.Where(b => !gegner.Contains(b.Heim, StringComparer.OrdinalIgnoreCase)).ToList(); + NurWochenendeSpeichern(gast.Key, gegner.Concat(neu.Select(b => b.Heim))); + gesetzt.AddRange(neu); + } + + return gesetzt; + } + + /// Nimmt mit gesetzte Sperren wieder zurück. + /// Die gesetzten Sperren. + public void WochenspielSperrenEntfernen(IEnumerable gesetzt) + { + ArgumentNullException.ThrowIfNull(gesetzt); + foreach (IGrouping gast in gesetzt.GroupBy(b => b.Gast, StringComparer.Ordinal)) + { + HashSet weg = gast.Select(b => b.Heim).ToHashSet(StringComparer.OrdinalIgnoreCase); + NurWochenendeSpeichern(gast.Key, NurWochenendeAus(Arbeitsstand, gast.Key).Where(n => !weg.Contains(n))); + } + } + + /// Lage der Halle: Spiellokal der Mannschaft (Nummer am Team, sonst die häufigste der Heimtermine, sonst 1). + private static Koordinate? HallenLage(DatenKnoten team, IReadOnlyList meldungen) + { + int nummer = DelphiKompatibel.ZahlLesen(team.Lesen("location"), 0); + if (nummer is < 1 or > 3) + { + nummer = team.KinderMitNamen("homegameday") + .Select(k => DelphiKompatibel.ZahlLesen(k.Lesen("location"), 0)) + .Where(n => n is >= 1 and <= 3) + .GroupBy(n => n) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(1); + } + + Spiellokaladresse? adresse = MeldungZu(team, meldungen) is Vereinsmeldung m + ? m.Spiellokale.GetValueOrDefault(nummer) ?? m.Spiellokale.Values.FirstOrDefault() + : GespeicherteAdresse(team, nummer); + return adresse switch + { + { Lage: Koordinate gps } => gps, + { Postleitzahl.Length: > 0 } a => Postleitzahlen.Lage(a.Postleitzahl, a.Ort), + _ => null, + }; + } + + private static Spiellokaladresse? GespeicherteAdresse(DatenKnoten team, int nummer) + { + string text = team.Lesen(AdressAttribut + nummer.ToString(CultureInfo.InvariantCulture)); + if (text.Length == 0) + { + text = team.Lesen(AdressAttribut + "1"); + } + + return text.Length == 0 ? null : Spiellokaladresse.Aus(text); + } + + private Dictionary HallenLagen(IReadOnlyList meldungen) + { + var lage = new Dictionary(StringComparer.Ordinal); + foreach (DatenKnoten team in Teams(Arbeitsstand)) + { + if (HallenLage(team, meldungen) is Koordinate k) + { + lage[team.Lesen("teamname")] = k; + } + } + + return lage; + } + + private HashSet Wochentagsgastgeber() => + Teams(Arbeitsstand).Where(HatWochentagsTermine).Select(t => t.Lesen("teamname")).ToHashSet(StringComparer.Ordinal); + + /// + /// Heimtermine unter der Woche und zugleich am Wochenende: Nur dann entscheidet die Sperre, wann gespielt wird. Unter der + /// Woche heißt Montag bis Donnerstag, Freitag nur, wenn er nicht zum Wochenende zählt. + /// + private bool HatWochentagsTermine(DatenKnoten team) + { + List wochenende = team.KinderMitNamen("homegameday") + .Select(k => DelphiKompatibel.DatumZeitStreng(k.Lesen("datetime"))) + .OfType() + .Select(t => t.DayOfWeek switch + { + DayOfWeek.Saturday or DayOfWeek.Sunday => true, + DayOfWeek.Friday => FreitagZaehltZumWochenende, + _ => false, + }) + .ToList(); + return wochenende.Contains(true) && wochenende.Contains(false); + } +} diff --git a/src/AndiGenerator.Application/Datenbearbeitung.cs b/src/AndiGenerator.Application/Datenbearbeitung.cs index cdd0844..cc0df4a 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.cs @@ -27,6 +27,12 @@ internal Datenbearbeitung(DatenKnoten stand, DatenKnoten? standard) this.standard = standard; } + /// + /// Holt einen Wert, der angibt, ob der Freitag bei der 60-km-Regel zum Wochenende zählt (aus den Einstellungen der + /// Staffel, Vorgabe ja); dann zählen für die Prüfung der Wochenspiele nur Heimtermine von Montag bis Donnerstag. + /// + public bool FreitagZaehltZumWochenende { get; init; } = true; + /// Holt einen Wert, der angibt, ob es einen Standard (click-TT-Import) gibt („Standard wiederherstellen“). public bool HatStandard => standard is not null; diff --git a/src/AndiGenerator.Application/Hallenentfernung.cs b/src/AndiGenerator.Application/Hallenentfernung.cs new file mode 100644 index 0000000..8308a23 --- /dev/null +++ b/src/AndiGenerator.Application/Hallenentfernung.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Entfernung zur Halle eines Gegners (nicht im Original). +/// Name des Gegners (Gastgeber). +/// Kilometer Luftlinie oder null, wenn eine der Adressen fehlt. +/// Der Gegner hat Heimtermine unter der Woche gemeldet. +public sealed record Hallenentfernung(string Gegner, double? Luftlinie, bool HeimUnterDerWoche); diff --git a/src/AndiGenerator.Application/Mannschaftsabgleich.cs b/src/AndiGenerator.Application/Mannschaftsabgleich.cs new file mode 100644 index 0000000..0e80e3a --- /dev/null +++ b/src/AndiGenerator.Application/Mannschaftsabgleich.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Abgleich einer Mannschaft der Staffel mit dem Bericht „Terminwünsche“. +/// Name der Mannschaft. +/// Die Mannschaft steht im Bericht. +/// Spiellokale des Vereins. +/// Zahl der Heimtermine, deren Spiellokal-Nummer aus dem Hallennamen gesetzt wird. +/// Bemerkung des Vereins, leer wenn keine. +/// Unterschiede zwischen Bericht und Spielplandaten (Heim- und Sperrtermine). +public sealed record Mannschaftsabgleich( + string Mannschaft, + bool Gefunden, + IReadOnlyList Spiellokale, + int NummernGesetzt, + string Bemerkung, + IReadOnlyList Abweichungen); diff --git a/src/AndiGenerator.Application/Mannschaftsstaerke.cs b/src/AndiGenerator.Application/Mannschaftsstaerke.cs new file mode 100644 index 0000000..5071b8d --- /dev/null +++ b/src/AndiGenerator.Application/Mannschaftsstaerke.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Spielstärke einer Mannschaft aus der Mannschaftsmeldung von click-TT. +/// Name der Mannschaft in der Staffel. +/// Schnitt der TTR-Werte der gewerteten Stammspieler; 0 ohne Aufstellung. +/// Zahl der gemeldeten Stammspieler. +public sealed record Mannschaftsstaerke(string Mannschaft, double Schnitt, int Stammspieler) +{ + /// Holt einen Wert, der angibt, ob für die Mannschaft eine Aufstellung gefunden wurde. + public bool HatAufstellung => Stammspieler > 0; +} diff --git a/src/AndiGenerator.Application/Plansitzung.cs b/src/AndiGenerator.Application/Plansitzung.cs index 8fe2e91..f359359 100644 --- a/src/AndiGenerator.Application/Plansitzung.cs +++ b/src/AndiGenerator.Application/Plansitzung.cs @@ -190,7 +190,7 @@ public static void NeueDateiAnlegen(string pfad) /// Erzeugt eine Arbeitskopie der Spielplandaten für den Dialog „Spielplandaten bearbeiten“. /// Die Arbeitskopie; der Standard ist bei click-TT-Dateien der unveränderte Import. public Datenbearbeitung DatenBearbeiten() => - new(stand, IstClickTtDatei ? ClickTtNachPlanDaten.Laden(Pfad) : null); + new(stand, IstClickTtDatei ? ClickTtNachPlanDaten.Laden(Pfad) : null) { FreitagZaehltZumWochenende = Optionen.FreitagZaehltZumWochenende }; /// /// Übernimmt eine bearbeitete Arbeitskopie wie das Original (DoOnAfterPlanChanged mit SavePlan): Bei diff --git a/src/AndiGenerator.Application/Setzlistenvorschlag.cs b/src/AndiGenerator.Application/Setzlistenvorschlag.cs new file mode 100644 index 0000000..15ee92a --- /dev/null +++ b/src/AndiGenerator.Application/Setzlistenvorschlag.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Vorschlag für die Setzliste aus den Mannschaftsaufstellungen. +/// Alle Mannschaften der Staffel, die stärkste zuerst; ohne Aufstellung am Ende. +/// Zahl der Stammspieler, deren Schnitt zählt (4 oder 6). +public sealed record Setzlistenvorschlag(IReadOnlyList Reihenfolge, int GewerteteSpieler) +{ + /// Holt die Mannschaften der Staffel, für die keine Aufstellung gefunden wurde. + public IReadOnlyList OhneAufstellung { get; } = Reihenfolge.Where(m => !m.HatAufstellung).Select(m => m.Mannschaft).ToList(); +} diff --git a/src/AndiGenerator.Application/Spiellokalangabe.cs b/src/AndiGenerator.Application/Spiellokalangabe.cs new file mode 100644 index 0000000..c13e05a --- /dev/null +++ b/src/AndiGenerator.Application/Spiellokalangabe.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Spiellokal eines Vereins aus dem Bericht „Terminwünsche“. +/// Nummer 1 bis 3. +/// Name der Halle. +/// Straße, Postleitzahl und Ort. +public sealed record Spiellokalangabe(int Nummer, string Name, string Adresse); diff --git a/src/AndiGenerator.Application/Terminwunschabgleich.cs b/src/AndiGenerator.Application/Terminwunschabgleich.cs new file mode 100644 index 0000000..314d1a6 --- /dev/null +++ b/src/AndiGenerator.Application/Terminwunschabgleich.cs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Persistence.Terminwuensche; + +namespace AndiGenerator.Application; + +/// Ergebnis des Abgleichs der Spielplandaten mit dem Bericht „Terminwünsche“ aus click-TT. +/// Die gelesenen Meldungen. +/// Je Mannschaft der Staffel der Abgleich. +public sealed record Terminwunschabgleich(IReadOnlyList Meldungen, IReadOnlyList Mannschaften) +{ + /// Holt die Zahl der Heimtermine, deren Spiellokal-Nummer gesetzt wird. + public int NummernGesetzt { get; } = Mannschaften.Sum(m => m.NummernGesetzt); +} diff --git a/src/AndiGenerator.Application/Wochenspielbefund.cs b/src/AndiGenerator.Application/Wochenspielbefund.cs new file mode 100644 index 0000000..82f5990 --- /dev/null +++ b/src/AndiGenerator.Application/Wochenspielbefund.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Ein Hinweis zur Liste „keine Auswärtsspiele unter der Woche“ (60-km-Regel) für ein Paar. +/// Mannschaft, die anreist. +/// Gastgeber (hat Heimtermine unter der Woche). +/// Entfernung der Hallen in Luftlinie (km). +/// Art des Hinweises. +public sealed record Wochenspielbefund(string Gast, string Heim, double Luftlinie, Wochenspielhinweis Art); diff --git a/src/AndiGenerator.Application/Wochenspielhinweis.cs b/src/AndiGenerator.Application/Wochenspielhinweis.cs new file mode 100644 index 0000000..d9d9a31 --- /dev/null +++ b/src/AndiGenerator.Application/Wochenspielhinweis.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Art eines Hinweises der Prüfung „Spiele unter der Woche“. +public enum Wochenspielhinweis +{ + /// Weiter als die Grenze, aber Spiele unter der Woche nicht gesperrt. + SperreFehlt, + + /// Näher als die Grenze, aber gesperrt (und nicht pauschal alle Gegner). + SperreUnnoetig, +} diff --git a/src/AndiGenerator.Application/Wochenspielpruefung.cs b/src/AndiGenerator.Application/Wochenspielpruefung.cs new file mode 100644 index 0000000..fee7f30 --- /dev/null +++ b/src/AndiGenerator.Application/Wochenspielpruefung.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Ergebnis der Prüfung „Spiele unter der Woche“ (60-km-Regel) anhand der Hallenadressen. +/// Grenze in Kilometern Luftlinie. +/// Die Hinweise, zuerst fehlende Sperren, jeweils die weitesten zuerst. +/// Mannschaften, die alle Gegner sperren (Vereinswunsch, kein Entfernungsfehler). +/// Mannschaften, deren Halle nicht gefunden wurde (keine Adresse oder unbekannte Postleitzahl). +/// Mannschaften mit Heimterminen unter der Woche und am Wochenende (nur bei ihnen wird geprüft). +/// Zahl der geprüften Paare (Gastgeber mit Heimterminen unter der Woche). +public sealed record Wochenspielpruefung( + double Grenze, + IReadOnlyList Befunde, + IReadOnlyList PauschalGesperrt, + IReadOnlyList OhneLage, + IReadOnlyList Wochentagsgastgeber, + int GeprueftePaare); diff --git a/src/AndiGenerator.Desktop/Linux/paket-bauen.sh b/src/AndiGenerator.Desktop/Linux/paket-bauen.sh index 91cc9d7..5dcc997 100755 --- a/src/AndiGenerator.Desktop/Linux/paket-bauen.sh +++ b/src/AndiGenerator.Desktop/Linux/paket-bauen.sh @@ -17,7 +17,7 @@ case "$rid" in esac # Portables Archiv: entpacken und ./AndiGenerator.NET starten. -tar -czf "$ziel/AndiGeneratorNET-$version-$rid.tar.gz" -C "$publish" . +tar -czf "$ziel/AndiGeneratorNET-$rid.tar.gz" -C "$publish" . # Debian-Paket: Programm unter /opt, Aufruf andigenerator-net, Eintrag im Startmenü (auf dem Chromebook im Launcher). wurzel="$(mktemp -d)" @@ -52,5 +52,7 @@ Description: Spielplan-Generator für Tischtennis-Staffeln aus den in click-TT gemeldeten Terminwünschen und exportiert sie für click-TT. STEUER -dpkg-deb --root-owner-group --build "$wurzel" "$ziel/andigenerator-net_${debversion}_${arch}.deb" +# Dateiname ohne Version: So bleibt der Link „releases/latest/download/…“ in README und Anleitung immer gültig; +# die Version steht im Paket selbst. +dpkg-deb --root-owner-group --build "$wurzel" "$ziel/andigenerator-net_${arch}.deb" rm -rf "$wurzel" diff --git a/src/AndiGenerator.Desktop/macOS/paket-bauen.sh b/src/AndiGenerator.Desktop/macOS/paket-bauen.sh index ff61820..3c70992 100755 --- a/src/AndiGenerator.Desktop/macOS/paket-bauen.sh +++ b/src/AndiGenerator.Desktop/macOS/paket-bauen.sh @@ -37,5 +37,6 @@ codesign --verify --deep --strict "$app" # Disk-Image: App-Paket und Verknüpfung zu /Applications zum Hineinziehen. ln -s /Applications "$inhalt/Programme" -hdiutil create -volname "AndiGenerator.NET" -srcfolder "$inhalt" -ov -format UDZO "$ziel/AndiGeneratorNET-$version-$name.dmg" +# Dateiname ohne Version, damit der Link „releases/latest/download/…“ immer gültig bleibt. +hdiutil create -volname "AndiGenerator.NET" -srcfolder "$inhalt" -ov -format UDZO "$ziel/AndiGeneratorNET-$name.dmg" rm -rf "$arbeit" diff --git a/src/AndiGenerator.Domain/Geo/Koordinate.cs b/src/AndiGenerator.Domain/Geo/Koordinate.cs new file mode 100644 index 0000000..de0b9f1 --- /dev/null +++ b/src/AndiGenerator.Domain/Geo/Koordinate.cs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Geo; + +/// Lage auf der Erde in Grad (WGS 84). +/// Geographische Breite. +/// Geographische Länge. +public readonly record struct Koordinate(double Breite, double Laenge) +{ + private const double Erdradius = 6371.0; + + /// Entfernung in Luftlinie (Großkreis). + /// Der andere Ort. + /// Die Entfernung in Kilometern. + public double Luftlinie(Koordinate ziel) + { + double b1 = Bogen(Breite); + double b2 = Bogen(ziel.Breite); + double db = b2 - b1; + double dl = Bogen(ziel.Laenge - Laenge); + double h = (Math.Sin(db / 2) * Math.Sin(db / 2)) + (Math.Cos(b1) * Math.Cos(b2) * Math.Sin(dl / 2) * Math.Sin(dl / 2)); + return 2 * Erdradius * Math.Asin(Math.Min(1.0, Math.Sqrt(h))); + } + + private static double Bogen(double grad) => grad * Math.PI / 180.0; +} diff --git a/src/AndiGenerator.Domain/Optionen/Korrekturen.cs b/src/AndiGenerator.Domain/Optionen/Korrekturen.cs index 94b9867..ef1260b 100644 --- a/src/AndiGenerator.Domain/Optionen/Korrekturen.cs +++ b/src/AndiGenerator.Domain/Optionen/Korrekturen.cs @@ -26,15 +26,21 @@ namespace AndiGenerator.Domain.Optionen; /// Auswärtskoppel. Das Original kann dort ein schon terminiertes Spiel neu terminieren (bei der Doppelrunde auch in eine /// andere Hauptrunde). /// +/// +/// Hallenbelegung (GetHallenBelegung): Sind die Hallen beider Spiele aus click-TT bekannt, zählt ein Heimspiel +/// einer Nachbar- oder Vereinsmannschaft nur bei gleicher Halle. Das Original vergleicht nur die Spiellokal-Nummer, die +/// bei Nachbarspielen immer leer ist – jedes Heimspiel des Vereins in der Nähe des Termins gilt dort als Belegung. +/// public sealed record Korrekturen( bool WunschterminNurVomEigentuemerFreigeben, bool LetzteUeberlappungSummieren, bool VollstaendigesRaster, - bool AuswaertskoppelNurOffeneSpiele) + bool AuswaertskoppelNurOffeneSpiele, + bool HallenNachNamen) { /// Alle Korrekturen (Standard). - public static Korrekturen Alle { get; } = new(true, true, true, true); + public static Korrekturen Alle { get; } = new(true, true, true, true, true); /// Keine Korrektur: wie das Original. - public static Korrekturen Keine { get; } = new(false, false, false, false); + public static Korrekturen Keine { get; } = new(false, false, false, false, false); } diff --git a/src/AndiGenerator.Domain/Stammdaten/Heimspieltermin.cs b/src/AndiGenerator.Domain/Stammdaten/Heimspieltermin.cs index 603507f..de4492a 100644 --- a/src/AndiGenerator.Domain/Stammdaten/Heimspieltermin.cs +++ b/src/AndiGenerator.Domain/Stammdaten/Heimspieltermin.cs @@ -21,4 +21,12 @@ public sealed record Heimspieltermin( Terminkoppelung Koppelung, Koppelprioritaet Prioritaet, TimeOnly? ZweiteUhrzeit, - bool ZweiteUhrzeitFuerAuswaertskoppel); + bool ZweiteUhrzeitFuerAuswaertskoppel) +{ + /// + /// Holt den Namen der Halle aus click-TT (courtHall, im Datenbaum Attribut courthall; nicht im Original) + /// oder leer, wenn unbekannt. Mit zählt die Hallenbelegung nur + /// Spiele in derselben Halle. + /// + public string Halle { get; init; } = string.Empty; +} diff --git a/src/AndiGenerator.Domain/Stammdaten/Nachbarspiel.cs b/src/AndiGenerator.Domain/Stammdaten/Nachbarspiel.cs index 1ee7137..fd73a5d 100644 --- a/src/AndiGenerator.Domain/Stammdaten/Nachbarspiel.cs +++ b/src/AndiGenerator.Domain/Stammdaten/Nachbarspiel.cs @@ -9,4 +9,8 @@ namespace AndiGenerator.Domain.Stammdaten; /// Name der Heimmannschaft. /// Name der Gastmannschaft. /// Abweichendes Spiellokal „1“ bis „5“ oder leer. -public sealed record Nachbarspiel(DateTime Zeitpunkt, string Heim, string Gast, string Spiellokal); +public sealed record Nachbarspiel(DateTime Zeitpunkt, string Heim, string Gast, string Spiellokal) +{ + /// Holt den Namen der Halle aus click-TT (courtHall; nicht im Original) oder leer, wenn unbekannt. + public string Halle { get; init; } = string.Empty; +} diff --git a/src/AndiGenerator.Domain/Turniere/Turnierbereitschaft.cs b/src/AndiGenerator.Domain/Turniere/Turnierbereitschaft.cs new file mode 100644 index 0000000..cca5e9f --- /dev/null +++ b/src/AndiGenerator.Domain/Turniere/Turnierbereitschaft.cs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Turniere; + +/// Was eine Mannschaft an einem Turniertag (Sonntag) kann – Antwort in der Meldung der Vereine. +[Flags] +public enum Turnierbereitschaft +{ + /// An diesem Tag kann die Mannschaft nicht spielen. + None = 0, + + /// Möglicher Heimspieltag: Die Mannschaft kann ein Turnier in ihrer Halle ausrichten. + Heim = 1, + + /// Möglicher Auswärtsspieltag: Die Mannschaft kann bei einem Turnier in einer fremden Halle spielen. + Auswaerts = 2, +} diff --git a/src/AndiGenerator.Domain/Turniere/Turniermeldung.cs b/src/AndiGenerator.Domain/Turniere/Turniermeldung.cs new file mode 100644 index 0000000..2d39f6f --- /dev/null +++ b/src/AndiGenerator.Domain/Turniere/Turniermeldung.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Turniere; + +/// +/// Meldung einer Mannschaft für eine Turnierrunde (z. B. Jugend-Verbandsoberliga): je Sonntag, ob sie ausrichten +/// und/oder auswärts spielen kann, dazu die Halle und ob dort zwei Spiele gleichzeitig möglich sind. +/// +/// Name der Mannschaft wie in der Meldung. +/// Bereitschaft je Sonntag; fehlt ein Tag, kann die Mannschaft dort nicht. +/// Halle für Heimspieltage (z. B. „Halle 1“). +/// In der Halle können zwei Spiele gleichzeitig laufen (Voraussetzung für ein 4er-Turnier). +/// Freitext der Meldung. +public sealed record Turniermeldung( + string Mannschaft, + IReadOnlyDictionary Tage, + string Halle, + bool ZweiTische, + string Bemerkung) +{ + /// Bereitschaft an einem Tag. + /// Der Tag. + /// Die Bereitschaft; , wenn der Tag nicht gemeldet ist. + public Turnierbereitschaft An(DateOnly tag) => Tage.TryGetValue(tag, out Turnierbereitschaft b) ? b : Turnierbereitschaft.None; +} diff --git a/src/AndiGenerator.Engine/Kern/KernDefinition.cs b/src/AndiGenerator.Engine/Kern/KernDefinition.cs index f9bc6f0..8092f75 100644 --- a/src/AndiGenerator.Engine/Kern/KernDefinition.cs +++ b/src/AndiGenerator.Engine/Kern/KernDefinition.cs @@ -17,6 +17,7 @@ internal sealed class KernDefinition public const int AnzahlArten = 16; private readonly Dictionary lokale = new(StringComparer.Ordinal) { [string.Empty] = 0 }; + private readonly Dictionary hallen = new(StringComparer.OrdinalIgnoreCase) { [string.Empty] = 0 }; private readonly int[][] sollJeTag; public KernDefinition(RefPlan plan) @@ -59,6 +60,7 @@ public KernDefinition(RefPlan plan) WunschParallele = termine.Select(w => w.ParalleleSpiele).ToArray(); WunschOptionen = termine.Select(w => w.Optionen).ToArray(); WunschLokal = termine.Select(w => LokalId(w.Spiellokal)).ToArray(); + WunschHalle = termine.Select(w => HalleId(w.Halle)).ToArray(); WunschRef = termine.ToArray(); Sperrtage = plan.Mannschaften.Select(m => m.Sperrtermine.ToArray()).ToArray(); @@ -130,7 +132,8 @@ public KernDefinition(RefPlan plan) s.Geschlecht == plan.Geschlecht, s.KeineParallelenSpiele, s.ParalleleHeimspiele, - Schluessel(zwangsSchluessel, s.Geschlecht + "\n" + s.Mannschaftsname))).ToArray()); + Schluessel(zwangsSchluessel, s.Geschlecht + "\n" + s.Mannschaftsname), + HalleId(s.Halle))).ToArray()); GleicheHeimtageTeams[t] = m.TeamsFuerGleicheHeimspieltage().Select(k => Schluessel(zwangsSchluessel, k)).ToArray(); } @@ -299,6 +302,9 @@ public KernDefinition(RefPlan plan) public int[] WunschLokal { get; } + /// Holt die Halle je Wunschtermin (0 = unbekannt). + public int[] WunschHalle { get; } + public RefWunschtermin[] WunschRef { get; } public double[] WunschMontag { get; } @@ -430,6 +436,21 @@ public int LokalId(string lokal) return id; } + /// Nummer einer Halle aus click-TT (0 = unbekannt); nur beim Aufbau verwenden (nicht threadsicher). + /// Hallenname. + /// Die Nummer. + public int HalleId(string halle) + { + string name = halle.Trim(); + if (!hallen.TryGetValue(name, out int id)) + { + id = hallen.Count; + hallen.Add(name, id); + } + + return id; + } + /// Original GetRoundNumberByDate. public int RundeZuDatum(double datum) { diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs index 22b214e..5d6a117 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Cache.cs @@ -29,8 +29,9 @@ private void AenderungenErmitteln() for (int s = 0; s < anzahl; s++) { int lokal = Lokal(s); + int halle = Halle(s); if (Datum[s] != cacheDatum[s] || Opt[s] != cacheOptionen[s] || MaxHeim[s] != cacheMaxHeim[s] - || NichtNotwendig[s] != cacheNichtNotwendig[s] || lokal != cacheLokal[s]) + || NichtNotwendig[s] != cacheNichtNotwendig[s] || lokal != cacheLokal[s] || halle != cacheHalle[s]) { geaendert[Heim[s]] = true; geaendert[Gast[s]] = true; @@ -39,6 +40,7 @@ private void AenderungenErmitteln() cacheMaxHeim[s] = MaxHeim[s]; cacheNichtNotwendig[s] = NichtNotwendig[s]; cacheLokal[s] = lokal; + cacheHalle[s] = halle; } } diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs index 234dabe..5d2704f 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.Kosten.cs @@ -912,7 +912,7 @@ private void Hallenbelegung(int t, int k, double faktor) int s = liste[i]; if (MaxHeim[s] > 0 && Heim[s] == t && Aktiv(s)) { - int belegung = Hallenbelegung(t, Datum[s], Tag[s], Lokal(s)); + int belegung = Hallenbelegung(t, Datum[s], Tag[s], Lokal(s), Halle(s)); if (belegung >= MaxHeim[s]) { KostenAnzahl[k] += belegung - MaxHeim[s] + 1; @@ -925,7 +925,7 @@ private void Hallenbelegung(int t, int k, double faktor) } /// Original GetHallenBelegung. - private int Hallenbelegung(int t, double datum, int tag, int lokal) + private int Hallenbelegung(int t, double datum, int tag, int lokal, int halle) { int anzahl = 0; double fenster = DelphiDatum.EncodeTime(1, 29); @@ -933,7 +933,7 @@ private int Hallenbelegung(int t, double datum, int tag, int lokal) { foreach (Nachbarspiel spiel in nachbarn) { - if (spiel.IstHeimspiel && Math.Abs(spiel.Datum - datum) <= fenster && lokal == spiel.Lokal) + if (spiel.IstHeimspiel && Math.Abs(spiel.Datum - datum) <= fenster && GleicheHalle(lokal, halle, spiel.Lokal, spiel.Halle)) { anzahl++; } @@ -946,7 +946,7 @@ private int Hallenbelegung(int t, double datum, int tag, int lokal) for (int i = 0; i < SpieleAnzahl[verein]; i++) { int s = liste[i]; - if (Heim[s] == verein && Gast[s] != t && Math.Abs(Datum[s] - datum) <= fenster && Lokal(s) == lokal) + if (Heim[s] == verein && Gast[s] != t && Math.Abs(Datum[s] - datum) <= fenster && GleicheHalle(Lokal(s), Halle(s), lokal, halle)) { anzahl++; } @@ -956,6 +956,10 @@ private int Hallenbelegung(int t, double datum, int tag, int lokal) return anzahl; } + /// Wie . + private bool GleicheHalle(int lokalA, int halleA, int lokalB, int halleB) => + D.Korrekturen.HallenNachNamen && halleA != 0 && halleB != 0 ? halleA == halleB : lokalA == lokalB; + private void ParalleleSpieleKosten(int t, int k, double faktor) { if (D.Nachbarspiele[t].Count == 0 && D.Vereinsteams[t].Length == 0) diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.cs b/src/AndiGenerator.Engine/Kern/KernPlan.cs index 3b36b31..22cc684 100644 --- a/src/AndiGenerator.Engine/Kern/KernPlan.cs +++ b/src/AndiGenerator.Engine/Kern/KernPlan.cs @@ -47,6 +47,7 @@ internal sealed partial class KernPlan private readonly int[] cacheMaxHeim; private readonly bool[] cacheNichtNotwendig; private readonly int[] cacheLokal; + private readonly int[] cacheHalle; private readonly int[][] cacheSpiele; private readonly int[] cacheSpieleAnzahl; private readonly bool[] geaendert; @@ -187,6 +188,7 @@ public KernPlan(RefPlan plan, KernDefinition definition) cacheMaxHeim = new int[gesamt]; cacheNichtNotwendig = new bool[gesamt]; cacheLokal = new int[gesamt]; + cacheHalle = new int[gesamt]; cacheSpiele = kapazitaet.Select(k => new int[k]).ToArray(); cacheSpieleAnzahl = new int[D.N]; geaendert = new bool[D.N]; @@ -301,6 +303,19 @@ public int Lokal(int s) return Wunsch[s] >= 0 ? D.WunschLokal[Wunsch[s]] : 0; } + /// Halle aus click-TT wie (0 = unbekannt, auch bei vorgegebenem Spiellokal). + /// Spiel. + /// Die Nummer der Halle. + public int Halle(int s) + { + if (UeberLokal[s] != 0 || Wunsch[s] < 0) + { + return 0; + } + + return D.WunschHalle[Wunsch[s]]; + } + /// Original GenerateMannschaftsRefGames mit SortMannschaftsDates. public void SpiellistenAufbauen() { diff --git a/src/AndiGenerator.Engine/Kern/Nachbarspiel.cs b/src/AndiGenerator.Engine/Kern/Nachbarspiel.cs index cd32519..d5a15fe 100644 --- a/src/AndiGenerator.Engine/Kern/Nachbarspiel.cs +++ b/src/AndiGenerator.Engine/Kern/Nachbarspiel.cs @@ -14,4 +14,5 @@ internal readonly record struct Nachbarspiel( bool GleichesGeschlecht, bool KeineParallelenSpiele, bool ParalleleHeimspiele, - int Schluessel); + int Schluessel, + int Halle); diff --git a/src/AndiGenerator.Engine/Referenz/Hallenvergleich.cs b/src/AndiGenerator.Engine/Referenz/Hallenvergleich.cs new file mode 100644 index 0000000..90eab3b --- /dev/null +++ b/src/AndiGenerator.Engine/Referenz/Hallenvergleich.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Referenz; + +/// Ob zwei Heimspiele in derselben Halle stattfinden (Hallenbelegung). +internal static class Hallenvergleich +{ + /// + /// Mit nach dem Hallennamen, wenn beide bekannt sind, sonst + /// wie das Original nach der Spiellokal-Nummer. + /// + /// Korrektur eingeschaltet. + /// Spiellokal des ersten Spiels. + /// Halle des ersten Spiels oder leer. + /// Spiellokal des zweiten Spiels. + /// Halle des zweiten Spiels oder leer. + /// true bei gleicher Halle. + public static bool Gleich(bool nachNamen, string lokalA, string halleA, string lokalB, string halleB) => + nachNamen && halleA.Length > 0 && halleB.Length > 0 + ? string.Equals(halleA.Trim(), halleB.Trim(), StringComparison.OrdinalIgnoreCase) + : lokalA == lokalB; +} diff --git a/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs b/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs index 713f5d9..1fca65b 100644 --- a/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs +++ b/src/AndiGenerator.Engine/Referenz/RefMannschaft.Kosten.cs @@ -793,7 +793,7 @@ private void Hallenbelegung(RefPlan plan, MannschaftsKostenart art, RefMannschaf foreach (RefSpiel spiel in Spiele.Where(s => s.DatumGueltig && plan.IstInZuPlanenderRunde(s.Datum) && s.DatumMaxHeim > 0 && ReferenceEquals(s.Heim, this))) { List? belegtVon = meldungen ? [] : null; - int belegung = Hallenbelegung(spiel.Datum, spiel.Lokal, plan, belegtVon); + int belegung = Hallenbelegung(spiel.Datum, spiel.Lokal, spiel.Halle, plan, belegtVon); if (belegung >= spiel.DatumMaxHeim) { kosten.Werte[(int)art].Anzahl += belegung - spiel.DatumMaxHeim + 1; diff --git a/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs b/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs index 4667143..c99a91e 100644 --- a/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs +++ b/src/AndiGenerator.Engine/Referenz/RefMannschaft.cs @@ -313,13 +313,14 @@ public HashSet TeamsFuerGleicheHeimspieltage() } /// Original GetHallenBelegung. - public int Hallenbelegung(double datum, string lokal, RefPlan plan, List? belegtVon) + public int Hallenbelegung(double datum, string lokal, string halle, RefPlan plan, List? belegtVon) { int anzahl = 0; double fenster = DelphiDatum.EncodeTime(1, 29); + bool nachNamen = plan.Optionen.Korrekturen.HallenNachNamen; if (Nachbarspiele.TryGetValue(DelphiDatum.Trunc(datum), out List? liste)) { - foreach (RefNachbarspiel spiel in liste.Where(s => s.IstHeimspiel && Math.Abs(s.Datum - datum) <= fenster && lokal == s.Lokal)) + foreach (RefNachbarspiel spiel in liste.Where(s => s.IstHeimspiel && Math.Abs(s.Datum - datum) <= fenster && Hallenvergleich.Gleich(nachNamen, lokal, halle, s.Lokal, s.Halle))) { anzahl++; belegtVon?.Add(spiel.Heim + " (" + spiel.Geschlecht + ")"); @@ -328,14 +329,12 @@ public int Hallenbelegung(double datum, string lokal, RefPlan plan, List foreach (RefMannschaft verein in VereinsteamsImPlan) { - foreach (RefSpiel spiel in verein.Spiele) + int gleich = verein.Spiele.Count(spiel => ReferenceEquals(spiel.Heim, verein) && !ReferenceEquals(spiel.Gast, this) + && Math.Abs(spiel.Datum - datum) <= fenster && Hallenvergleich.Gleich(nachNamen, spiel.Lokal, spiel.Halle, lokal, halle)); + anzahl += gleich; + for (int i = 0; i < gleich && belegtVon is not null; i++) { - if (ReferenceEquals(spiel.Heim, verein) && !ReferenceEquals(spiel.Gast, this) - && Math.Abs(spiel.Datum - datum) <= fenster && spiel.Lokal == lokal) - { - anzahl++; - belegtVon?.Add(verein.TeamName + " (" + plan.Geschlecht + ")"); - } + belegtVon.Add(verein.TeamName + " (" + plan.Geschlecht + ")"); } } @@ -479,6 +478,7 @@ private void HeimspielLaden(Heimspieltermin termin) Datum = datum, ParalleleSpiele = termin.MaxParalleleSpiele, Spiellokal = termin.Spiellokal.Length > 0 ? termin.Spiellokal : StandardLokal, + Halle = termin.Halle, }; Wunschtermine.Add(wunsch); @@ -540,6 +540,7 @@ private void NachbarmannschaftLaden(Nachbarmannschaft nachbar) Heim = spiel.Heim, Gast = spiel.Gast, Lokal = lokal, + Halle = spiel.Halle, KeineParallelenSpiele = nachbar.KeineParallelenSpiele, ParalleleHeimspiele = nachbar.ParalleleHeimspiele, }; diff --git a/src/AndiGenerator.Engine/Referenz/RefNachbarspiel.cs b/src/AndiGenerator.Engine/Referenz/RefNachbarspiel.cs index 9f62488..d82876b 100644 --- a/src/AndiGenerator.Engine/Referenz/RefNachbarspiel.cs +++ b/src/AndiGenerator.Engine/Referenz/RefNachbarspiel.cs @@ -23,6 +23,8 @@ internal sealed class RefNachbarspiel public string Lokal { get; init; } = string.Empty; + public string Halle { get; init; } = string.Empty; + public bool KeineParallelenSpiele { get; init; } public bool ParalleleHeimspiele { get; init; } diff --git a/src/AndiGenerator.Engine/Referenz/RefSpiel.cs b/src/AndiGenerator.Engine/Referenz/RefSpiel.cs index 4574678..b3415af 100644 --- a/src/AndiGenerator.Engine/Referenz/RefSpiel.cs +++ b/src/AndiGenerator.Engine/Referenz/RefSpiel.cs @@ -33,6 +33,9 @@ internal sealed class RefSpiel(RefMannschaft heim, RefMannschaft gast) /// Original getLocation. public string Lokal => UeberschriebenesLokal.Length > 0 ? UeberschriebenesLokal : Wunschtermin?.Spiellokal ?? string.Empty; + /// Halle aus click-TT wie : unbekannt (leer), wenn das Spiellokal vorgegeben ist. + public string Halle => UeberschriebenesLokal.Length > 0 ? string.Empty : Wunschtermin?.Halle ?? string.Empty; + /// Original setDate. public void SetzeDatum(double datum, Wunschterminoptionen optionen, int maxHeim, bool festesDatum, bool nichtNotwendig, RefWunschtermin? wunschtermin) { diff --git a/src/AndiGenerator.Engine/Referenz/RefWunschtermin.cs b/src/AndiGenerator.Engine/Referenz/RefWunschtermin.cs index b909378..8b664d1 100644 --- a/src/AndiGenerator.Engine/Referenz/RefWunschtermin.cs +++ b/src/AndiGenerator.Engine/Referenz/RefWunschtermin.cs @@ -19,12 +19,16 @@ internal sealed class RefWunschtermin public string Spiellokal { get; set; } = string.Empty; + /// Holt oder setzt den Hallennamen aus click-TT (nicht im Original); leer = unbekannt. + public string Halle { get; set; } = string.Empty; + public RefWunschtermin Kopie() => new() { Datum = Datum, ParalleleSpiele = ParalleleSpiele, KoppelZweitzeit = KoppelZweitzeit, Spiellokal = Spiellokal, + Halle = Halle, Optionen = Optionen, }; } diff --git a/src/AndiGenerator.Engine/Referenz/Terminwunschauswertung.cs b/src/AndiGenerator.Engine/Referenz/Terminwunschauswertung.cs index 6d59b3f..5ab06b0 100644 --- a/src/AndiGenerator.Engine/Referenz/Terminwunschauswertung.cs +++ b/src/AndiGenerator.Engine/Referenz/Terminwunschauswertung.cs @@ -137,7 +137,7 @@ private static Terminwunschtext Wunschzeile(RefPlan plan, RefMannschaft m, RefWu } bool spielfrei = plan.IstSpielfreierTag(w.Datum); - List belegtVon = HallenbelegungDurchNachbarn(m, w.Datum, w.Spiellokal); + List belegtVon = HallenbelegungDurchNachbarn(m, w, plan.Optionen.Korrekturen.HallenNachNamen); if (belegtVon.Count > 0 && w.ParalleleSpiele > 0 && w.ParalleleSpiele <= belegtVon.Count) { text += ", Halle ist belegt von: " + string.Join(", ", belegtVon); @@ -167,11 +167,11 @@ private static Terminwunschtext Wunschzeile(RefPlan plan, RefMannschaft m, RefWu } /// Original GetHallenBelegung(…, IgnoreThisPlan = true): nur Heimspiele der Nachbarmannschaften. - private static List HallenbelegungDurchNachbarn(RefMannschaft m, double datum, string lokal) + private static List HallenbelegungDurchNachbarn(RefMannschaft m, RefWunschtermin w, bool nachNamen) { double fenster = DelphiDatum.EncodeTime(1, 29); - return m.Nachbarspiele.TryGetValue(DelphiDatum.Trunc(datum), out List? liste) - ? liste.Where(s => s.IstHeimspiel && Math.Abs(s.Datum - datum) <= fenster && s.Lokal == lokal).Select(s => s.Heim + " (" + s.Geschlecht + ")").ToList() + return m.Nachbarspiele.TryGetValue(DelphiDatum.Trunc(w.Datum), out List? liste) + ? liste.Where(s => s.IstHeimspiel && Math.Abs(s.Datum - w.Datum) <= fenster && Hallenvergleich.Gleich(nachNamen, s.Lokal, s.Halle, w.Spiellokal, w.Halle)).Select(s => s.Heim + " (" + s.Geschlecht + ")").ToList() : []; } diff --git a/src/AndiGenerator.Engine/Turniere/Ausrichterverteilung.cs b/src/AndiGenerator.Engine/Turniere/Ausrichterverteilung.cs new file mode 100644 index 0000000..325f09d --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Ausrichterverteilung.cs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Verteilt die Ausrichter über alle Turniere zugleich: Vorrang hat, dass jede Mannschaft ein Turnier ausrichtet; dann +/// möglichst wenige Gäste, die an dem Tag nicht auswärts können; dann möglichst gleichmäßig. Bis zu rund einer +/// Million Möglichkeiten wird vollständig gesucht, sonst Turnier für Turnier. +/// +internal static class Ausrichterverteilung +{ + private const double Grenze = 1 << 20; + + /// Wählt je Turnier einen Ausrichter. + /// Die Turniere. + /// Alle Mannschaften der Staffel. + /// Ob eine Mannschaft das Turnier ausrichten kann. + /// Zahl der Gäste, die bei diesem Ausrichter nicht auswärts können. + /// Je Turnier der Ausrichter oder null, wenn keiner der möglichen kann. + public static string?[] Verteilen( + IReadOnlyList turniere, + IReadOnlyList mannschaften, + Func kann, + Func verstoesse) + { + string?[][] optionen = turniere.Select(t => Optionen(t, kann)).ToArray(); + var suche = new Suche(turniere, mannschaften, optionen, verstoesse); + if (optionen.Aggregate(1.0, (p, o) => p * o.Length) <= Grenze) + { + suche.Weiter(0, 0); + } + else + { + suche.Schrittweise(); + } + + return suche.Beste; + } + + private static string?[] Optionen(Turnier turnier, Func kann) + { + string?[] moeglich = turnier.MoeglicheAusrichter.Where(m => kann(turnier, m)).ToArray(); + return moeglich.Length > 0 ? moeglich : [null]; + } + + /// Stand der Suche: aktuelle Wahl, Zähler je Mannschaft und beste Verteilung. + private sealed class Suche( + IReadOnlyList turniere, + IReadOnlyList mannschaften, + string?[][] optionen, + Func verstoesse) + { + private readonly string?[] aktuell = new string?[turniere.Count]; + private readonly Dictionary zaehler = mannschaften.Distinct(StringComparer.Ordinal).ToDictionary(m => m, _ => 0, StringComparer.Ordinal); + private (int OhneAusrichtung, int Verstoesse, int Streuung) besteWertung = (int.MaxValue, int.MaxValue, int.MaxValue); + + /// Holt die beste gefundene Verteilung. + public string?[] Beste { get; } = new string?[turniere.Count]; + + /// Vollständige Suche ab Turnier . + /// Nächstes Turnier. + /// Verstöße der Turniere vor . + public void Weiter(int index, int verstoesseBisher) + { + if (index == turniere.Count) + { + Abschliessen(verstoesseBisher); + return; + } + + Turnier turnier = turniere[index]; + foreach (string? ausrichter in optionen[index]) + { + aktuell[index] = ausrichter; + Zaehlen(turnier, ausrichter, 1); + Weiter(index + 1, verstoesseBisher + verstoesse(turnier, ausrichter)); + Zaehlen(turnier, ausrichter, -1); + } + } + + /// Turnier für Turnier: bisher am seltensten ausgerichtet, dann wenigste Verstöße. + public void Schrittweise() + { + for (int i = 0; i < turniere.Count; i++) + { + Turnier turnier = turniere[i]; + string? gewaehlt = optionen[i] + .OrderBy(a => a is null ? int.MaxValue : zaehler.GetValueOrDefault(a)) + .ThenBy(a => verstoesse(turnier, a)) + .First(); + Beste[i] = gewaehlt; + Zaehlen(turnier, gewaehlt, 1); + } + } + + private void Zaehlen(Turnier turnier, string? ausrichter, int schritt) + { + if (ausrichter is not null && turnier.Form != Turnierform.Einzel) + { + zaehler[ausrichter] = zaehler.GetValueOrDefault(ausrichter) + schritt; + } + } + + private void Abschliessen(int verstoesseGesamt) + { + (int, int, int) wertung = (zaehler.Values.Count(n => n == 0), verstoesseGesamt, zaehler.Values.Sum(n => n * n)); + if (wertung.CompareTo(besteWertung) < 0) + { + besteWertung = wertung; + aktuell.CopyTo(Beste, 0); + } + } + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Turnier.cs b/src/AndiGenerator.Engine/Turniere/Turnier.cs new file mode 100644 index 0000000..f8d9bec --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turnier.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Stammdaten; + +namespace AndiGenerator.Engine.Turniere; + +/// Ein Turniertag: Mannschaften, die sich an einem Tag in einer Halle treffen, und ihre Spiele. +/// Der Tag. +/// Form des Turniers. +/// Beteiligte Mannschaften. +/// Spiele nach Uhrzeit. +/// +/// Mannschaften, die nach dem Heimrecht der Spiele Ausrichter sein können: beim Dreier die mit zwei Heimspielen, beim +/// Vierer die beiden mit je zwei Heimspielen (eine davon spielt in Wahrheit auswärts), bei der Einzelbegegnung die +/// Heimmannschaft. +/// +public sealed record Turnier( + DateOnly Tag, + Turnierform Form, + IReadOnlyList Mannschaften, + IReadOnlyList Spiele, + IReadOnlyList MoeglicheAusrichter); diff --git a/src/AndiGenerator.Engine/Turniere/Turnierbewertung.cs b/src/AndiGenerator.Engine/Turniere/Turnierbewertung.cs new file mode 100644 index 0000000..2ac41c4 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turnierbewertung.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ein geprüftes Turnier. +/// Das Turnier. +/// Gewählter Ausrichter oder null, wenn keiner der möglichen ausrichten kann. +/// Verstöße gegen die Meldungen. +/// Bei Befunden: Tage, an denen das Turnier ohne Befund stattfinden könnte. +public sealed record Turnierbewertung(Turnier Turnier, string? Ausrichter, IReadOnlyList Hinweise, IReadOnlyList Ausweichtage); diff --git a/src/AndiGenerator.Engine/Turniere/Turniererkennung.cs b/src/AndiGenerator.Engine/Turniere/Turniererkennung.cs new file mode 100644 index 0000000..8ad705d --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turniererkennung.cs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Stammdaten; + +namespace AndiGenerator.Engine.Turniere; + +/// Erkennt in einem Spielplan die Turniertage: je Tag die Gruppen von Mannschaften, die gegeneinander spielen. +public static class Turniererkennung +{ + /// Zerlegt die terminierten Spiele in Turniere. + /// Spiele des Plans; Spiele ohne Termin werden übergangen. + /// Die Turniere nach Tag und erster Uhrzeit. + public static IReadOnlyList Erkennen(IEnumerable spiele) + { + ArgumentNullException.ThrowIfNull(spiele); + var ergebnis = new List(); + foreach (IGrouping tag in spiele.Where(s => s.Zeitpunkt is not null).GroupBy(s => DateOnly.FromDateTime(s.Zeitpunkt!.Value))) + { + foreach (List gruppe in Gruppen(tag.ToList())) + { + ergebnis.Add(Einordnen(tag.Key, gruppe)); + } + } + + return ergebnis.OrderBy(t => t.Tag).ThenBy(t => t.Spiele[0].Zeitpunkt).ToList(); + } + + /// Paarung unabhängig vom Heimrecht. + /// Eine Mannschaft. + /// Die andere Mannschaft. + /// Die beiden Namen in fester Reihenfolge. + public static (string Erste, string Zweite) Paar(string a, string b) => string.CompareOrdinal(a, b) <= 0 ? (a, b) : (b, a); + + /// Gruppen von Spielen, deren Mannschaften über Spiele miteinander verbunden sind. + private static IEnumerable> Gruppen(List spiele) + { + var vertreter = new Dictionary(StringComparer.Ordinal); + string Wurzel(string mannschaft) + { + string m = mannschaft; + while (vertreter.TryGetValue(m, out string? v) && v != m) + { + m = v; + } + + return m; + } + + foreach (Spiel spiel in spiele) + { + vertreter.TryAdd(spiel.Heim, spiel.Heim); + vertreter.TryAdd(spiel.Gast, spiel.Gast); + vertreter[Wurzel(spiel.Heim)] = Wurzel(spiel.Gast); + } + + return spiele.GroupBy(s => Wurzel(s.Heim), StringComparer.Ordinal).Select(g => g.ToList()); + } + + private static Turnier Einordnen(DateOnly tag, List gruppe) + { + List spiele = gruppe.OrderBy(s => s.Zeitpunkt).ThenBy(s => s.Heim, StringComparer.Ordinal).ToList(); + List mannschaften = spiele.SelectMany(s => new[] { s.Heim, s.Gast }).Distinct(StringComparer.Ordinal).ToList(); + var heimspiele = mannschaften.ToDictionary(m => m, m => spiele.Count(s => s.Heim == m), StringComparer.Ordinal); + bool jederZweimal = mannschaften.TrueForAll(m => spiele.Count(s => s.Heim == m || s.Gast == m) == 2); + bool paareEinzeln = spiele.Select(s => Paar(s.Heim, s.Gast)).Distinct().Count() == spiele.Count; + Turnierform form = (mannschaften.Count, spiele.Count, jederZweimal && paareEinzeln) switch + { + (2, 1, _) => Turnierform.Einzel, + (3, 3, true) => Turnierform.Dreier, + (4, 4, true) => Turnierform.Vierer, + _ => Turnierform.Unbekannt, + }; + List ausrichter = form == Turnierform.Einzel ? [spiele[0].Heim] : mannschaften.Where(m => heimspiele[m] == 2).ToList(); + return new Turnier(tag, form, mannschaften, spiele, ausrichter.Count > 0 ? ausrichter : mannschaften); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Turnierform.cs b/src/AndiGenerator.Engine/Turniere/Turnierform.cs new file mode 100644 index 0000000..18ef772 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turnierform.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Form eines Turniertags. +public enum Turnierform +{ + /// Spiele, die zu keiner bekannten Form passen. + Unbekannt, + + /// 3 Mannschaften, jeder gegen jeden (3 Spiele an zwei Tischen nacheinander, z. B. 10, 13 und 16 Uhr). + Dreier, + + /// + /// Einzelbegegnung: Bei gerader Mannschaftszahl bleibt für jede Mannschaft eine Paarung übrig, die einzeln + /// (möglichst mit kurzer Fahrt) ausgetragen wird. + /// + Einzel, + + /// + /// 4 Mannschaften in zwei Runden an zwei Tischen (4 Spiele, z. B. 11 und 14 Uhr): Ausrichter und eine zweite + /// Mannschaft spielen jeweils gegen die beiden übrigen, aber nicht gegeneinander. + /// + Vierer, +} diff --git a/src/AndiGenerator.Engine/Turniere/Turnierpruefung.cs b/src/AndiGenerator.Engine/Turniere/Turnierpruefung.cs new file mode 100644 index 0000000..5b3fd49 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turnierpruefung.cs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text; +using AndiGenerator.Domain.Turniere; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Prüft einen Plan aus Turniertagen gegen die Meldungen der Mannschaften: Form der Turniere, jede Paarung genau einmal, +/// höchstens ein Turnier je Mannschaft und Tag, Ausrichter kann zuhause (und beim Vierer zwei Spiele parallel), die +/// übrigen können auswärts, jede Mannschaft richtet mindestens einmal ein Turnier aus. Einzelbegegnungen (bei gerader +/// Mannschaftszahl) werden ebenso auf Heim und Auswärts geprüft, zählen aber nicht als ausgerichtetes Turnier; mehr als +/// eine je Mannschaft wird gemeldet. Für Turniere mit Befund werden Ausweichtage vorgeschlagen. Es zählen nur die +/// Meldungen; Sperrtermine und spielfreie Tage aus click-TT gelten für Turnierrunden nicht. +/// +public static class Turnierpruefung +{ + /// Prüft die Turniere gegen die Meldungen. + /// Die Turniere des Plans. + /// Alle Mannschaften der Staffel. + /// Die Meldungen. + /// Das Ergebnis mit dem gewählten Ausrichter je Turnier und allen Befunden. + public static Turnierpruefungsergebnis Pruefen( + IReadOnlyList turniere, + IReadOnlyList mannschaften, + IReadOnlyList meldungen) + { + ArgumentNullException.ThrowIfNull(turniere); + ArgumentNullException.ThrowIfNull(mannschaften); + ArgumentNullException.ThrowIfNull(meldungen); + var kontext = new Kontext( + turniere, + mannschaften.ToDictionary(m => m, m => Meldung(m, meldungen), StringComparer.Ordinal), + meldungen.SelectMany(m => m.Tage.Keys).Distinct().Order().ToList()); + var befunde = new List(); + befunde.AddRange(mannschaften.Where(m => kontext.MeldungJe[m] is null).Select(m => $"{m}: keine Meldung gefunden – an allen Tagen als möglich angenommen.")); + befunde.AddRange(Paarungen(turniere, mannschaften)); + befunde.AddRange(Doppelte(turniere)); + befunde.AddRange(turniere.Where(t => t.Form == Turnierform.Einzel).SelectMany(t => t.Mannschaften) + .GroupBy(m => m, StringComparer.Ordinal).Where(g => g.Count() > 1) + .Select(g => $"{g.Key} hat {g.Count()} Einzelbegegnungen.")); + + string?[] gewaehlt = Ausrichterverteilung.Verteilen( + turniere, + mannschaften, + (t, m) => Kann(t, m, kontext.MeldungJe), + (t, a) => t.Mannschaften.Count(m => m != a && !KannAuswaerts(t, m, kontext.MeldungJe))); + var ergebnis = new List(); + var ausgerichtet = mannschaften.ToDictionary(m => m, _ => 0, StringComparer.Ordinal); + for (int i = 0; i < turniere.Count; i++) + { + Turnierbewertung bewertung = Bewerten(turniere[i], gewaehlt[i], kontext); + if (bewertung.Ausrichter is { } ausrichter && turniere[i].Form != Turnierform.Einzel) + { + ausgerichtet[ausrichter] = ausgerichtet.GetValueOrDefault(ausrichter) + 1; + } + + befunde.AddRange(bewertung.Hinweise.Select(h => $"{Datum(turniere[i].Tag)}: {h}")); + ergebnis.Add(bewertung); + } + + befunde.AddRange(mannschaften.Where(m => ausgerichtet[m] == 0).Select(m => $"{m} richtet kein Turnier aus.")); + return new Turnierpruefungsergebnis(ergebnis, befunde, ausgerichtet); + } + + /// Sucht die Meldung zu einer Mannschaft: gleicher Name, sonst ohne Satz- und Leerzeichen, sonst Teil des Namens. + /// Name aus der click-TT-Datei. + /// Die Meldungen. + /// Die Meldung oder null. + public static Turniermeldung? Meldung(string mannschaft, IReadOnlyList meldungen) + { + ArgumentNullException.ThrowIfNull(meldungen); + string gesucht = Kern(mannschaft); + return meldungen.FirstOrDefault(m => string.Equals(m.Mannschaft, mannschaft, StringComparison.OrdinalIgnoreCase)) + ?? meldungen.FirstOrDefault(m => Kern(m.Mannschaft) == gesucht) + ?? meldungen.FirstOrDefault(m => Kern(m.Mannschaft).Length > 3 && (gesucht.Contains(Kern(m.Mannschaft), StringComparison.Ordinal) || Kern(m.Mannschaft).Contains(gesucht, StringComparison.Ordinal))); + } + + /// Text einer Bereitschaft. + /// Die Bereitschaft. + /// Z. B. „nur zuhause“. + public static string Text(Turnierbereitschaft b) => b switch + { + Turnierbereitschaft.Heim | Turnierbereitschaft.Auswaerts => "zuhause und auswärts", + Turnierbereitschaft.Heim => "nur zuhause", + Turnierbereitschaft.Auswaerts => "nur auswärts", + _ => "kann nicht", + }; + + private static Turnierbewertung Bewerten(Turnier turnier, string? ausrichter, Kontext kontext) + { + var hinweise = new List(); + if (turnier.Form == Turnierform.Unbekannt) + { + hinweise.Add($"Keine bekannte Form ({turnier.Mannschaften.Count} Mannschaften, {turnier.Spiele.Count} Spiele)."); + } + + if (ausrichter is null) + { + string gruende = string.Join("; ", turnier.MoeglicheAusrichter.Select(m => $"{m}: {Grund(m, turnier, kontext.MeldungJe)}")); + hinweise.Add($"Kein möglicher Ausrichter ({gruende})."); + } + + hinweise.AddRange(turnier.Mannschaften.Where(m => m != ausrichter && !KannAuswaerts(turnier, m, kontext.MeldungJe)) + .Select(m => $"{m} kann an diesem Tag nicht auswärts spielen ({Text(kontext.MeldungJe[m]!.An(turnier.Tag))}).")); + return new Turnierbewertung(turnier, ausrichter, hinweise, hinweise.Count > 0 ? Ausweichtage(turnier, kontext) : []); + } + + /// + /// Tage aus den Meldungen, an denen das Turnier ohne Befund stattfinden könnte: keine der Mannschaften spielt dort + /// ein anderes Turnier, ein möglicher Ausrichter kann zuhause und alle anderen können auswärts. + /// + private static List Ausweichtage(Turnier turnier, Kontext kontext) + { + var belegt = kontext.Turniere.Where(t => !ReferenceEquals(t, turnier)) + .SelectMany(t => t.Mannschaften.Select(m => (t.Tag, m))) + .ToHashSet(); + return kontext.Tage + .Where(tag => tag != turnier.Tag && turnier.Mannschaften.All(m => !belegt.Contains((tag, m)))) + .Where(tag => Ausrichtbar(turnier with { Tag = tag }, kontext.MeldungJe)) + .ToList(); + } + + private static bool Ausrichtbar(Turnier turnier, Dictionary meldungJe) => + turnier.MoeglicheAusrichter.Any(a => Kann(turnier, a, meldungJe) && turnier.Mannschaften.All(m => m == a || KannAuswaerts(turnier, m, meldungJe))); + + /// Ausrichter muss zuhause können, beim Vierer mit zwei Spielen parallel; ohne Meldung gilt alles als möglich. + private static bool Kann(Turnier turnier, string mannschaft, Dictionary meldungJe) => + meldungJe.GetValueOrDefault(mannschaft) is not { } meldung + || (meldung.An(turnier.Tag).HasFlag(Turnierbereitschaft.Heim) && (turnier.Form != Turnierform.Vierer || meldung.ZweiTische)); + + private static bool KannAuswaerts(Turnier turnier, string mannschaft, Dictionary meldungJe) => + meldungJe.GetValueOrDefault(mannschaft) is not { } meldung || meldung.An(turnier.Tag).HasFlag(Turnierbereitschaft.Auswaerts); + + private static string Grund(string mannschaft, Turnier turnier, Dictionary meldungJe) + { + Turniermeldung meldung = meldungJe[mannschaft]!; + Turnierbereitschaft b = meldung.An(turnier.Tag); + return b.HasFlag(Turnierbereitschaft.Heim) ? "Halle nur für ein Spiel gleichzeitig" : Text(b); + } + + /// Jede Paarung genau einmal. + private static IEnumerable Paarungen(IReadOnlyList turniere, IReadOnlyList mannschaften) + { + var anzahl = new Dictionary<(string Erste, string Zweite), int>(); + foreach ((string Erste, string Zweite) paar in turniere.SelectMany(t => t.Spiele).Select(s => Turniererkennung.Paar(s.Heim, s.Gast))) + { + anzahl[paar] = anzahl.GetValueOrDefault(paar) + 1; + } + + for (int i = 0; i < mannschaften.Count; i++) + { + for (int j = i + 1; j < mannschaften.Count; j++) + { + (string Erste, string Zweite) paar = Turniererkennung.Paar(mannschaften[i], mannschaften[j]); + int n = anzahl.GetValueOrDefault(paar); + if (n != 1) + { + yield return n == 0 + ? $"Paarung {paar.Erste} – {paar.Zweite} fehlt." + : $"Paarung {paar.Erste} – {paar.Zweite} kommt {n}-mal vor."; + } + } + } + } + + /// Höchstens ein Turnier je Mannschaft und Tag. + private static IEnumerable Doppelte(IReadOnlyList turniere) => + turniere.SelectMany(t => t.Mannschaften.Select(m => (t.Tag, Mannschaft: m))) + .GroupBy(x => x) + .Where(g => g.Count() > 1) + .Select(g => $"{Datum(g.Key.Tag)}: {g.Key.Mannschaft} spielt in {g.Count()} Turnieren."); + + private static string Datum(DateOnly tag) => tag.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + /// Name nur aus Buchstaben und Ziffern, klein, Umlaute vereinfacht. + private static string Kern(string name) + { + var text = new StringBuilder(); + foreach (char c in name.ToLowerInvariant().Replace("ä", "ae", StringComparison.Ordinal).Replace("ö", "oe", StringComparison.Ordinal) + .Replace("ü", "ue", StringComparison.Ordinal).Replace("ß", "ss", StringComparison.Ordinal).Where(char.IsLetterOrDigit)) + { + text.Append(c); + } + + return text.ToString(); + } + + /// Was alle Turniere einer Prüfung gemeinsam brauchen. + /// Alle Turniere des Plans. + /// Meldung je Mannschaft der Staffel oder null. + /// Alle Tage der Meldungen, aufsteigend. + private sealed record Kontext( + IReadOnlyList Turniere, + Dictionary MeldungJe, + IReadOnlyList Tage); +} diff --git a/src/AndiGenerator.Engine/Turniere/Turnierpruefungsergebnis.cs b/src/AndiGenerator.Engine/Turniere/Turnierpruefungsergebnis.cs new file mode 100644 index 0000000..0e86bd8 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Turnierpruefungsergebnis.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ergebnis von . +/// Die Turniere mit Ausrichter und Hinweisen. +/// Alle Verstöße und Auffälligkeiten. +/// Wie oft jede Mannschaft ausrichtet. +public sealed record Turnierpruefungsergebnis( + IReadOnlyList Turniere, + IReadOnlyList Befunde, + IReadOnlyDictionary Ausgerichtet); diff --git a/src/AndiGenerator.Import/AndiGenerator.Import.csproj b/src/AndiGenerator.Import/AndiGenerator.Import.csproj new file mode 100644 index 0000000..e502002 --- /dev/null +++ b/src/AndiGenerator.Import/AndiGenerator.Import.csproj @@ -0,0 +1,12 @@ + + + AndiGenerator.Import + + + + + + + + + diff --git a/src/AndiGenerator.Import/TerminwunschPdf.cs b/src/AndiGenerator.Import/TerminwunschPdf.cs new file mode 100644 index 0000000..30f7dc7 --- /dev/null +++ b/src/AndiGenerator.Import/TerminwunschPdf.cs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Persistence.Terminwuensche; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Core; + +namespace AndiGenerator.Import; + +/// +/// Liest den Bericht „Terminwünsche“ aus click-TT als PDF: PdfPig liefert die Wörter mit Lage, daraus entstehen Zeilen, +/// die auswertet. Einziger Ort, an dem die PDF-Bibliothek verwendet wird. +/// +public static class TerminwunschPdf +{ + /// Wörter, deren Grundlinie weniger als dieser Abstand trennt, gehören zu einer Zeile. + private const double Zeilentoleranz = 2.0; + + /// Liest die Meldungen aus einer PDF-Datei. + /// Pfad der PDF-Datei. + /// Die Meldungen. + /// Die Datei ist kein lesbares PDF oder kein Bericht „Terminwünsche“. + /// Die Datei kann nicht gelesen werden. + public static IReadOnlyList Laden(string pfad) => TerminwunschBericht.Lesen(Textzeilen(pfad)); + + /// Liest die Textzeilen aller Seiten (oben nach unten, links nach rechts). + /// Pfad der PDF-Datei. + /// Die Zeilen. + /// Die Datei ist kein lesbares PDF. + public static IReadOnlyList Textzeilen(string pfad) + { + try + { + using PdfDocument dokument = PdfDocument.Open(pfad); + var zeilen = new List(); + int nummer = 0; + foreach (Page seite in dokument.GetPages()) + { + zeilen.AddRange(Seitenzeilen(nummer, seite)); + nummer++; + } + + return zeilen; + } + catch (PdfDocumentFormatException ex) + { + throw new FormatException("Die Datei ist kein lesbares PDF: " + ex.Message, ex); + } + } + + private static List Seitenzeilen(int nummer, Page seite) + { + // PdfPig misst von unten. Zeilen ergeben sich aus der Grundlinie der Buchstaben – die Oberkante hängt vom Zeichen ab + // („-“ und „,“ liegen tiefer). Manche Berichte zeichnen Text doppelt (fett), solche Wörter zählen einmal. + var woerter = seite.GetWords() + .Where(w => !string.IsNullOrWhiteSpace(w.Text) && w.Letters.Count > 0) + .Select(w => (Oben: seite.Height - w.Letters[0].StartBaseLine.Y, Links: w.BoundingBox.Left, w.Text)) + .DistinctBy(w => (Math.Round(w.Oben), Math.Round(w.Links), w.Text)) + .OrderBy(w => w.Oben) + .ThenBy(w => w.Links) + .ToList(); + + var zeilen = new List(); + var aktuell = new List<(double Oben, double Links, string Text)>(); + foreach (var wort in woerter) + { + if (aktuell.Count > 0 && wort.Oben - aktuell[0].Oben > Zeilentoleranz) + { + zeilen.Add(Zeile(nummer, aktuell)); + aktuell = []; + } + + aktuell.Add(wort); + } + + if (aktuell.Count > 0) + { + zeilen.Add(Zeile(nummer, aktuell)); + } + + return zeilen; + } + + private static Textzeile Zeile(int seite, List<(double Oben, double Links, string Text)> woerter) => + new(seite, woerter[0].Oben, woerter.OrderBy(w => w.Links).Select(w => new Textwort(w.Links, w.Text)).ToList()); +} diff --git a/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj b/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj index 0341703..8b26a1c 100644 --- a/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj +++ b/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj @@ -3,6 +3,10 @@ AndiGenerator.Persistence true + + + + diff --git a/src/AndiGenerator.Persistence/Aufstellungen/Mannschaftsaufstellung.cs b/src/AndiGenerator.Persistence/Aufstellungen/Mannschaftsaufstellung.cs new file mode 100644 index 0000000..6088a11 --- /dev/null +++ b/src/AndiGenerator.Persistence/Aufstellungen/Mannschaftsaufstellung.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Persistence.Aufstellungen; + +/// Aufstellung einer Mannschaft aus click-TT: die TTR-Werte ihrer Stammspieler in der Reihenfolge der Meldung. +/// Name der Mannschaft wie in click-TT, z. B. „TTC Wirges II“. +/// TTR-Werte der Stammspieler (ohne Reservespieler) nach ihrem Rang in der Mannschaft. +public sealed record Mannschaftsaufstellung(string Mannschaft, IReadOnlyList Stammspieler) +{ + /// Schnitt der besten Stammspieler nach Rang, z. B. der ersten vier bei Vierer-Mannschaften. + /// Zahl der gewerteten Spieler. + /// Der Schnitt; 0, wenn es keine Stammspieler gibt. + public double Schnitt(int anzahl) + { + List gewertet = Stammspieler.Take(Math.Max(1, anzahl)).ToList(); + return gewertet.Count == 0 ? 0.0 : gewertet.Average(); + } +} diff --git a/src/AndiGenerator.Persistence/Aufstellungen/MannschaftsaufstellungenCsv.cs b/src/AndiGenerator.Persistence/Aufstellungen/MannschaftsaufstellungenCsv.cs new file mode 100644 index 0000000..e15533a --- /dev/null +++ b/src/AndiGenerator.Persistence/Aufstellungen/MannschaftsaufstellungenCsv.cs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text; +using AndiGenerator.Persistence.Gemeinsam; + +namespace AndiGenerator.Persistence.Aufstellungen; + +/// +/// Liest den click-TT-Export „Mannschaftsmeldung“ bzw. „Mannschaftsaufstellungen“ einer Staffel (CSV mit Semikolon, +/// Windows-1252): je Spieler eine Zeile mit Mannschaft der Staffel, Mannschaftsnummer und Rang des Spielers, TTR-Wert und +/// Status. Die Datei enthält alle Mannschaften der Vereine; gewertet werden nur die Spieler der Mannschaft, die in der +/// Staffel spielt (bei „TTC Wirges II“ die der 2. Mannschaft), ohne Reservespieler (Status „RES“). Namen und +/// Geburtsdaten werden nicht gelesen. +/// +public static class MannschaftsaufstellungenCsv +{ + private static readonly Dictionary Roemisch = new() { ['I'] = 1, ['V'] = 5, ['X'] = 10 }; + + /// Liest die Aufstellungen aus einer Datei (UTF-8 oder Windows-1252). + /// Pfad der CSV-Datei. + /// Je Mannschaft der Staffel eine Aufstellung, in der Reihenfolge der Datei. + /// Die benötigten Spalten fehlen. + public static IReadOnlyList Laden(string pfad) + { + byte[] inhalt = File.ReadAllBytes(pfad); + string text; + try + { + text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(inhalt); + } + catch (DecoderFallbackException) + { + text = DelphiKompatibel.Windows1252.GetString(inhalt); + } + + return Lesen(text.TrimStart('')); + } + + /// Liest die Aufstellungen aus dem Text einer CSV-Datei. + /// Inhalt der Datei. + /// Je Mannschaft der Staffel eine Aufstellung, in der Reihenfolge der Datei. + /// Die benötigten Spalten fehlen. + public static IReadOnlyList Lesen(string text) + { + ArgumentNullException.ThrowIfNull(text); + List zeilen = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(z => z.Split(';').Select(f => f.Trim().Trim('"')).ToArray()) + .ToList(); + if (zeilen.Count == 0) + { + throw new FormatException("Die Datei mit den Mannschaftsaufstellungen ist leer."); + } + + string[] kopf = zeilen[0]; + int mannschaft = Spalte(kopf, "MannschaftName"); + int nummer = Spalte(kopf, "SpielerMannschaftNr"); + int rang = Spalte(kopf, "SpielerRang"); + int ttr = Spalte(kopf, "TTR"); + int status = Array.IndexOf(kopf, "SpielerStatus"); + if (Math.Min(Math.Min(mannschaft, nummer), Math.Min(rang, ttr)) < 0) + { + throw new FormatException("Die Datei ist keine Mannschaftsmeldung aus click-TT (es fehlen die Spalten MannschaftName, SpielerMannschaftNr, SpielerRang oder TTR)."); + } + + var spieler = new Dictionary>(StringComparer.Ordinal); + foreach (string[] z in zeilen.Skip(1).Where(z => z.Length > Math.Max(Math.Max(mannschaft, nummer), Math.Max(rang, ttr)))) + { + string name = z[mannschaft]; + if (!spieler.TryGetValue(name, out List<(int Rang, int Ttr)>? liste)) + { + liste = []; + spieler.Add(name, liste); + } + + bool reserve = status >= 0 && status < z.Length && z[status].Contains("RES", StringComparison.Ordinal); + if (!reserve && Zahl(z[nummer]) == Nummer(name) && Zahl(z[ttr]) is int wert && wert > 0) + { + liste.Add((Zahl(z[rang]) ?? int.MaxValue, wert)); + } + } + + return spieler.Select(e => new Mannschaftsaufstellung(e.Key, e.Value.OrderBy(s => s.Rang).Select(s => s.Ttr).ToList())).ToList(); + } + + /// Mannschaftsnummer aus dem Namen: römische Zahl am Ende („TTC Wirges II“ → 2), sonst 1. + /// Name der Mannschaft. + /// Die Nummer. + public static int Nummer(string name) + { + ArgumentNullException.ThrowIfNull(name); + string[] woerter = name.Trim().Split(' '); + string letztes = woerter[^1]; + if (letztes.Length == 0 || !letztes.All(Roemisch.ContainsKey)) + { + return 1; + } + + int summe = 0; + for (int i = 0; i < letztes.Length; i++) + { + int wert = Roemisch[letztes[i]]; + summe += i + 1 < letztes.Length && Roemisch[letztes[i + 1]] > wert ? -wert : wert; + } + + return summe; + } + + private static int Spalte(string[] kopf, string name) => Array.IndexOf(kopf, name); + + private static int? Zahl(string text) => + int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int wert) ? wert : null; +} diff --git a/src/AndiGenerator.Persistence/Geo/Postleitzahlen.cs b/src/AndiGenerator.Persistence/Geo/Postleitzahlen.cs new file mode 100644 index 0000000..2127430 --- /dev/null +++ b/src/AndiGenerator.Persistence/Geo/Postleitzahlen.cs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.IO.Compression; +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Persistence.Geo; + +/// +/// Lage deutscher Postleitzahlen (je Postleitzahl und Ort) aus GeoNames (www.geonames.org, CC BY 4.0), eingebettet als +/// plz-de.csv.gz. Ohne Netz und ohne Annahme über eine Region innerhalb Deutschlands. +/// +public static class Postleitzahlen +{ + private static readonly Lazy>> Tabelle = new(Laden); + + /// Lage zu Postleitzahl und Ort; gibt es zur Postleitzahl mehrere Orte, zählt der passende, sonst ihr Mittel. + /// Fünfstellige Postleitzahl. + /// Ort, z. B. „Neuwied-Engers“ (darf leer sein). + /// Die Lage oder null, wenn die Postleitzahl unbekannt ist. + public static Koordinate? Lage(string postleitzahl, string ort) + { + ArgumentNullException.ThrowIfNull(postleitzahl); + ArgumentNullException.ThrowIfNull(ort); + if (!Tabelle.Value.TryGetValue(postleitzahl.Trim(), out List<(string Ort, Koordinate Lage)>? orte)) + { + return null; + } + + string gesucht = Kern(ort); + List<(string Ort, Koordinate Lage)> passend = gesucht.Length == 0 + ? [] + : orte.Where(o => o.Ort.Length > 0 && (gesucht.Contains(o.Ort, StringComparison.Ordinal) || o.Ort.Contains(gesucht, StringComparison.Ordinal))).ToList(); + List<(string Ort, Koordinate Lage)> auswahl = passend.Count > 0 ? passend : orte; + return new Koordinate(auswahl.Average(o => o.Lage.Breite), auswahl.Average(o => o.Lage.Laenge)); + } + + private static Dictionary> Laden() + { + var tabelle = new Dictionary>(StringComparer.Ordinal); + using Stream quelle = typeof(Postleitzahlen).Assembly.GetManifestResourceStream("plz-de.csv.gz") + ?? throw new InvalidOperationException("Die Postleitzahlen-Tabelle fehlt im Programm."); + using var entpackt = new GZipStream(quelle, CompressionMode.Decompress); + using var leser = new StreamReader(entpackt, System.Text.Encoding.UTF8); + while (leser.ReadLine() is string zeile) + { + string[] teile = zeile.Split(';'); + if (zeile.StartsWith('#') || teile.Length != 4) + { + continue; + } + + if (!tabelle.TryGetValue(teile[0], out List<(string Ort, Koordinate Lage)>? orte)) + { + orte = []; + tabelle.Add(teile[0], orte); + } + + orte.Add((Kern(teile[1]), new Koordinate( + double.Parse(teile[2], CultureInfo.InvariantCulture), + double.Parse(teile[3], CultureInfo.InvariantCulture)))); + } + + return tabelle; + } + + /// Nur Buchstaben und Ziffern, klein: „Mülheim - Kärlich“ und „Mülheim-Kärlich“ sind gleich. + private static string Kern(string text) => string.Concat(text.Where(char.IsLetterOrDigit)).ToLowerInvariant(); +} diff --git a/src/AndiGenerator.Persistence/Geo/plz-de.csv.gz b/src/AndiGenerator.Persistence/Geo/plz-de.csv.gz new file mode 100644 index 0000000..b6a4614 Binary files /dev/null and b/src/AndiGenerator.Persistence/Geo/plz-de.csv.gz differ diff --git a/src/AndiGenerator.Persistence/Plandaten/ClickTtNachPlanDaten.cs b/src/AndiGenerator.Persistence/Plandaten/ClickTtNachPlanDaten.cs index 8add3b6..b3f6be0 100644 --- a/src/AndiGenerator.Persistence/Plandaten/ClickTtNachPlanDaten.cs +++ b/src/AndiGenerator.Persistence/Plandaten/ClickTtNachPlanDaten.cs @@ -145,6 +145,7 @@ private static void HeimterminAnlegen(DatenKnoten team, ClickTtHeimspieltermin t knoten.Setzen("ausweichtermin", termin.Ausweichtermin); knoten.Setzen("location", string.Empty); knoten.Setzen("parallelgames", termin.MaxParalleleSpiele); + HalleSetzen(knoten, termin.Halle); if (!string.IsNullOrEmpty(termin.Koppelwunsch)) { @@ -224,6 +225,16 @@ private static void NachbarmannschaftAnlegen(DatenKnoten plan, DatenKnoten team, spielKnoten.Setzen("datetime", zeitpunkt); spielKnoten.Setzen("hometeamname", spiel.Heim); spielKnoten.Setzen("guestteamname", spiel.Gast); + HalleSetzen(spielKnoten, spiel.Halle); + } + } + + /// Hallenname aus click-TT übernehmen (nicht im Original; dort ohne Wirkung). + private static void HalleSetzen(DatenKnoten knoten, string? halle) + { + if (!string.IsNullOrWhiteSpace(halle)) + { + knoten.Setzen(StaffelAbbildung.HallenAttribut, halle.Trim()); } } diff --git a/src/AndiGenerator.Persistence/Plandaten/DatenKnoten.cs b/src/AndiGenerator.Persistence/Plandaten/DatenKnoten.cs index f847f32..eabf059 100644 --- a/src/AndiGenerator.Persistence/Plandaten/DatenKnoten.cs +++ b/src/AndiGenerator.Persistence/Plandaten/DatenKnoten.cs @@ -15,6 +15,9 @@ namespace AndiGenerator.Persistence.Plandaten; /// public sealed class DatenKnoten { + /// Attribute nur aus dem click-TT-Import (nicht im Original), die nie in eine Änderungsdatei kommen. + private static readonly string[] NurImport = [StaffelAbbildung.HallenAttribut]; + private readonly OrderedDictionary attribute = new(StringComparer.Ordinal); /// Erzeugt einen Knoten und hängt ihn (falls angegeben) als letztes Kind an an. @@ -223,7 +226,9 @@ public void Zusammenfuehren(DatenKnoten quelle) /// /// Macht diesen Knoten zur Differenz „nachher gegenüber vorher“ (Original TDataObject.Diff): Schlüsselattribute immer, /// geänderte Attribute mit dem Nachher-Wert (fehlend = leer), gelöschte Kinder zuerst, dann alle Nachher-Kinder als - /// dosNew-Kopie bzw. rekursive Differenz (auch unveränderte Kinder erzeugen einen Knoten). + /// dosNew-Kopie bzw. rekursive Differenz (auch unveränderte Kinder erzeugen einen Knoten). Attribute, die nur + /// aus dem click-TT-Import stammen (Hallenname, nicht im Original), kommen nie in die Differenz: Die Änderungsdatei bleibt + /// wie beim Original, der Wert steht ohnehin im Import. /// /// Neuer Stand. /// Ausgangsstand. @@ -239,7 +244,7 @@ public void DifferenzBilden(DatenKnoten nachher, DatenKnoten vorher) Setzen(wert.Key, wert.Value); } - foreach (string name in nachher.attribute.Keys.Concat(vorher.attribute.Keys).Where(n => nachher.Lesen(n) != vorher.Lesen(n)).ToList()) + foreach (string name in nachher.attribute.Keys.Concat(vorher.attribute.Keys).Where(n => nachher.Lesen(n) != vorher.Lesen(n) && !NurImport.Contains(n)).ToList()) { Setzen(name, nachher.Lesen(name)); Status = KnotenStatus.Geaendert; @@ -249,6 +254,7 @@ public void DifferenzBilden(DatenKnoten nachher, DatenKnoten vorher) { var geloescht = new DatenKnoten(kind.Name, this); geloescht.AttributeZuweisen(kind); + geloescht.ImportattributeEntfernen(); geloescht.Status = KnotenStatus.Geloescht; } @@ -259,6 +265,7 @@ public void DifferenzBilden(DatenKnoten nachher, DatenKnoten vorher) if (index < 0) { neu.Zuweisen(kind); + neu.ImportattributeEntfernen(); neu.Status = KnotenStatus.Neu; } else @@ -312,4 +319,18 @@ private bool IstGleichEineRichtung(DatenKnoten anderer) return attributeGleich && kinderGleich.TrueForAll(gleich => gleich); } + + /// Entfernt die nur importierten Attribute aus diesem Knoten und allen Kindern. + private void ImportattributeEntfernen() + { + foreach (string name in NurImport) + { + attribute.Remove(name); + } + + foreach (DatenKnoten kind in Kinder) + { + kind.ImportattributeEntfernen(); + } + } } diff --git a/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs b/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs index 0fe8ff4..e04ea54 100644 --- a/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs +++ b/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs @@ -20,6 +20,9 @@ public static class StaffelAbbildung /// Attribut am Knoten plan mit der Einteilung der Kriterien (nicht im Original, dort ohne Wirkung). public const string KriterienAttribut = "kriterienstufen"; + /// Attribut an homegameday und sistergame mit dem Hallennamen aus click-TT (nicht im Original). + public const string HallenAttribut = "courthall"; + /// Liest die Stammdaten aus dem Datenbaum. /// Wurzelknoten plan (click-TT-Import, ggf. mit eingearbeiteten Änderungen). /// Die Staffel. @@ -151,7 +154,10 @@ private static Heimspieltermin HeimspielterminLesen(DatenKnoten knoten) Koppelung: koppelung, Prioritaet: PrioritaetLesen(knoten), ZweiteUhrzeit: zweite.Length == 0 ? null : DelphiKompatibel.TimeFromString(zweite), - ZweiteUhrzeitFuerAuswaertskoppel: knoten.LesenBool("coupleauswaertssecondtime")); + ZweiteUhrzeitFuerAuswaertskoppel: knoten.LesenBool("coupleauswaertssecondtime")) + { + Halle = knoten.Lesen(HallenAttribut), + }; } private static Koppelprioritaet PrioritaetLesen(DatenKnoten knoten) => knoten.LesenZahl("coupleprio") switch @@ -204,7 +210,10 @@ private static Setzliste SetzlisteLesen(DatenKnoten ranking) ZeitpunktPflicht(knoten.Lesen("datetime")), knoten.Lesen("hometeamname"), knoten.Lesen("guestteamname"), - Spiellokale.Korrigieren(knoten.Lesen("location"))); + Spiellokale.Korrigieren(knoten.Lesen("location"))) + { + Halle = knoten.Lesen(HallenAttribut), + }; private static List SpieleLesen(DatenKnoten? liste) => liste is null ? [] : liste.KinderMitNamen("game").Select(SpielLesen).ToList(); @@ -285,6 +294,11 @@ private static void HeimspielterminSchreiben(DatenKnoten team, Heimspieltermin t { knoten.Setzen("couplesecondtime", zweite.ToString("HH:mm", System.Globalization.CultureInfo.InvariantCulture)); } + + if (termin.Halle.Length > 0) + { + knoten.Setzen(HallenAttribut, termin.Halle); + } } private static void NachbarmannschaftSchreiben(DatenKnoten team, Nachbarmannschaft nachbar) @@ -306,6 +320,11 @@ private static void NachbarmannschaftSchreiben(DatenKnoten team, Nachbarmannscha { game.Setzen("location", spiel.Spiellokal); } + + if (spiel.Halle.Length > 0) + { + game.Setzen(HallenAttribut, spiel.Halle); + } } } diff --git a/src/AndiGenerator.Persistence/Terminwuensche/GemeldeterHeimtermin.cs b/src/AndiGenerator.Persistence/Terminwuensche/GemeldeterHeimtermin.cs new file mode 100644 index 0000000..b55dffc --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/GemeldeterHeimtermin.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// Ein Terminwunsch (Heimtermin) aus dem Bericht „Terminwünsche“. +/// Datum und Uhrzeit. +/// Name des Spiellokals. +/// „max. Heimspiele“, z. B. „2“ oder „unendlich“. +/// „Heimkoppelspiel“: „-“, „wenn möglich“ oder „unbedingt“. +public sealed record GemeldeterHeimtermin(DateTime Zeitpunkt, string Halle, string MaxHeimspiele, string Heimkoppel); diff --git a/src/AndiGenerator.Persistence/Terminwuensche/Spiellokaladresse.cs b/src/AndiGenerator.Persistence/Terminwuensche/Spiellokaladresse.cs new file mode 100644 index 0000000..4da8a07 --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/Spiellokaladresse.cs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text.RegularExpressions; +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// Spiellokal eines Vereins aus dem Bericht „Terminwünsche“, z. B. „Haybachhalle, Am Bandweidenweg, 55270 Klein-Winternheim“. +/// Name der Halle wie in click-TT (so steht er auch an den Heimterminen der XML-Datei). +/// Straße, leer wenn nicht angegeben. +/// Postleitzahl, leer wenn nicht erkennbar. +/// Ort. +public sealed partial record Spiellokaladresse(string Name, string Strasse, string Postleitzahl, string Ort) +{ + /// + /// Holt die GPS-Lage, wenn der Verein sie zur Adresse angegeben hat (z. B. „GPS:50.63599°N 7.35227 °E“), sonst + /// null. Sie ist genauer als die Lage aus der Postleitzahl. + /// + public Koordinate? Lage { get; init; } + + /// Holt die Adresse als eine Zeile (mit GPS-Lage, falls bekannt). + public string Text + { + get + { + List teile = new[] { Name, Strasse, (Postleitzahl + " " + Ort).Trim() }.Where(t => t.Length > 0).ToList(); + if (Lage is Koordinate k) + { + teile.Add(string.Create(CultureInfo.InvariantCulture, $"GPS:{k.Breite:0.#####}°N {k.Laenge:0.#####}°E")); + } + + return string.Join(", ", teile); + } + } + + /// + /// Zerlegt eine Adresszeile: Name, Straße, „PLZ Ort“ durch Kommas getrennt. Angaben nach „PLZ Ort“ (etwa „Fax:“) + /// entfallen; eine GPS-Angabe wird als gelesen. + /// + /// Die Zeile. + /// Die Adresse. + public static Spiellokaladresse Aus(string zeile) + { + ArgumentNullException.ThrowIfNull(zeile); + Koordinate? lage = null; + string rest = zeile; + Match gps = GpsAngabe().Match(zeile); + if (gps.Success) + { + lage = GpsLage(gps); + rest = zeile.Remove(gps.Index, gps.Length); + } + + List teile = rest.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0).ToList(); + if (teile.Count == 0) + { + return new Spiellokaladresse(string.Empty, string.Empty, string.Empty, string.Empty) { Lage = lage }; + } + + int stelle = teile.FindLastIndex(t => PlzOrt().IsMatch(t)); + if (stelle < 1) + { + return new Spiellokaladresse(teile[0], string.Join(", ", teile.Skip(1)), string.Empty, string.Empty) { Lage = lage }; + } + + Match ort = PlzOrt().Match(teile[stelle]); + return new Spiellokaladresse(teile[0], string.Join(", ", teile.Skip(1).Take(stelle - 1)), ort.Groups[1].Value, ort.Groups[2].Value) { Lage = lage }; + } + + /// Breite und Länge der GPS-Angabe; nur nördliche Breite und östliche Länge in plausiblen Grenzen zählen. + private static Koordinate? GpsLage(Match gps) + { + static double Zahl(Group g) => double.Parse(g.Value.Replace(',', '.'), CultureInfo.InvariantCulture); + double breite = Zahl(gps.Groups[1]); + double laenge = Zahl(gps.Groups[2]); + return breite is > 0 and < 90 && laenge is > 0 and < 180 ? new Koordinate(breite, laenge) : null; + } + + [GeneratedRegex(@"^(\d{5})\s+(.+)$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PlzOrt(); + + [GeneratedRegex(@"GPS:\s*(\d{1,2}[.,]\d+)\s*°?\s*N\s*,?\s*(\d{1,3}[.,]\d+)\s*°?\s*[EO]", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 1000)] + private static partial Regex GpsAngabe(); +} diff --git a/src/AndiGenerator.Persistence/Terminwuensche/TerminwunschBericht.cs b/src/AndiGenerator.Persistence/Terminwuensche/TerminwunschBericht.cs new file mode 100644 index 0000000..f6a1c52 --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/TerminwunschBericht.cs @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// +/// Liest den click-TT-Bericht „Terminwünsche“ (nu.Dokument 019, als Textzeilen einer PDF-Datei): je Mannschaft Verein +/// mit Vereinsnummer, Spiellokale 1 bis 3 mit Adresse, Bemerkung, Terminwünsche der Vor- und Rückrunde und +/// Sperrtermine. Kontaktpersonen werden übergangen. Die Spalten der Tabellen ergeben sich aus ihrer Kopfzeile. +/// +public static partial class TerminwunschBericht +{ + /// Kopf jeder Seite (Verband, Staffel, „Terminwünsche“) liegt oberhalb dieser Höhe. + private const double Seitenkopf = 90; + + private enum Abschnitt + { + Sonstiges, + Terminwuensche, + Sperrtermine, + } + + /// Liest die Meldungen aus den Zeilen aller Seiten. + /// Die Zeilen in Lesereihenfolge. + /// Die Meldungen in der Reihenfolge des Berichts. + /// Im Text steht keine Mannschaft – vermutlich ein anderer Bericht. + public static IReadOnlyList Lesen(IEnumerable zeilen) + { + ArgumentNullException.ThrowIfNull(zeilen); + List inhalt = zeilen + .Where(z => z.Oben >= Seitenkopf && !z.Text.StartsWith("nu.Dokument", StringComparison.Ordinal)) + .ToList(); + IReadOnlyList meldungen = new Leser(inhalt).Alle(); + if (meldungen.Count == 0) + { + throw new FormatException("Die Datei ist kein Bericht „Terminwünsche“ aus click-TT (keine Mannschaft gefunden)."); + } + + return meldungen; + } + + [GeneratedRegex(@"^(.+) \((\d+)\) - (.+)$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Mannschaftskopf(); + + [GeneratedRegex(@"^Spiellokal (\d)$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Spiellokalkopf(); + + [GeneratedRegex(@"^\d+\. Spieltag", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Spieltagszeile(); + + [GeneratedRegex(@"(\d\d\.\d\d\.\d{4}) (\d\d:\d\d)", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DatumUhrzeit(); + + [GeneratedRegex(@"\d\d\.\d\d\.\d{4}", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Tagesdatum(); + + /// Spalten der Tabelle „Terminwünsche“ (linke Kanten aus der Kopfzeile). + private sealed record Spalten(double Datum, double Lokal, double Max, double Koppel); + + /// Liest Zeile für Zeile; je Mannschaft entsteht eine Meldung. + private sealed class Leser(List zeilen) + { + private readonly List fertig = []; + private int index; + private Entwurf? aktuell; + private Abschnitt abschnitt; + private Spalten? spalten; + + public IReadOnlyList Alle() + { + while (index < zeilen.Count) + { + Textzeile zeile = zeilen[index]; + index++; + Verarbeiten(zeile); + } + + Abschliessen(); + return fertig; + } + + private static bool IstLokalEnde(string text) => + Spiellokalkopf().IsMatch(text) || text == "Zusatzinformationen" || text.StartsWith("Tel.", StringComparison.Ordinal) + || text.StartsWith("Mobil", StringComparison.Ordinal) || text.StartsWith("E-Mail", StringComparison.Ordinal) + || text.StartsWith("Fax", StringComparison.Ordinal); + + private static string Anhaengen(string bisher, string neu) + { + if (neu.Length == 0) + { + return bisher; + } + + return bisher.EndsWith('-') ? bisher + neu : (bisher + " " + neu).Trim(); + } + + private void Verarbeiten(Textzeile zeile) + { + string text = zeile.Text; + Match kopf = Mannschaftskopf().Match(text); + if (kopf.Success) + { + Abschliessen(); + aktuell = new Entwurf(kopf.Groups[1].Value, int.Parse(kopf.Groups[2].Value, CultureInfo.InvariantCulture), kopf.Groups[3].Value); + abschnitt = Abschnitt.Sonstiges; + return; + } + + if (aktuell is null) + { + return; + } + + if (Spiellokalkopf().Match(text) is { Success: true } lokal) + { + SpiellokalLesen(int.Parse(lokal.Groups[1].Value, CultureInfo.InvariantCulture)); + } + else if (text.StartsWith("Bemerkung:", StringComparison.Ordinal)) + { + BemerkungLesen(text["Bemerkung:".Length..].Trim()); + } + else if (text.StartsWith("Terminwünsche ", StringComparison.Ordinal)) + { + abschnitt = Abschnitt.Terminwuensche; + } + else if (text == "Sperrtermine") + { + abschnitt = Abschnitt.Sperrtermine; + } + else if (text is "Auswärtskoppelspiele" || text.StartsWith("Gesperrte Auswärtsspiele", StringComparison.Ordinal)) + { + abschnitt = Abschnitt.Sonstiges; + } + else + { + Inhalt(zeile); + } + } + + private void Inhalt(Textzeile zeile) + { + if (abschnitt == Abschnitt.Sperrtermine) + { + foreach (Match datum in Tagesdatum().Matches(zeile.Text)) + { + aktuell!.Sperrtermine.Add(DateOnly.ParseExact(datum.Value, "dd.MM.yyyy", CultureInfo.InvariantCulture)); + } + } + else if (abschnitt == Abschnitt.Terminwuensche) + { + TerminLesen(zeile); + } + } + + private void TerminLesen(Textzeile zeile) + { + if (zeile.Text.StartsWith("Spieltag ", StringComparison.Ordinal)) + { + double Links(string wort) => zeile.Woerter.FirstOrDefault(w => w.Text == wort)?.Links ?? double.MaxValue; + spalten = new Spalten(Links("Datum"), Links("Spiellokal"), Links("max."), Links("Heimkoppelspiel")); + return; + } + + if (spalten is null) + { + return; + } + + if (Spieltagszeile().IsMatch(zeile.Text) + && DatumUhrzeit().Match(zeile.Spalte(spalten.Datum, spalten.Lokal)) is { Success: true } zeit) + { + aktuell!.Termine.Add(new TerminEntwurf( + DateTime.ParseExact(zeit.Groups[1].Value + " " + zeit.Groups[2].Value, "dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture), + zeile.Spalte(spalten.Lokal, spalten.Max), + zeile.Spalte(spalten.Max, spalten.Koppel), + zeile.Spalte(spalten.Koppel, double.MaxValue))); + } + else if (aktuell!.Termine.Count > 0) + { + // Umbruch innerhalb der Zeile eines Termins (lange Hallennamen, „wenn möglich“). + TerminEntwurf letzter = aktuell.Termine[^1]; + letzter.Halle = Anhaengen(letzter.Halle, zeile.Spalte(spalten.Lokal, spalten.Max)); + letzter.Max = Anhaengen(letzter.Max, zeile.Spalte(spalten.Max, spalten.Koppel)); + letzter.Koppel = Anhaengen(letzter.Koppel, zeile.Spalte(spalten.Koppel, double.MaxValue)); + } + } + + /// Adresse bis zur nächsten Überschrift; Telefon und E-Mail des Spiellokals werden übergangen. + private void SpiellokalLesen(int nummer) + { + var teile = new List(); + while (index < zeilen.Count && !IstLokalEnde(zeilen[index].Text)) + { + teile.Add(zeilen[index].Text); + index++; + } + + string adresse = string.Join(' ', teile).Trim().TrimEnd(',').Trim(); + if (adresse.Length > 0) + { + aktuell!.Spiellokale[nummer] = Spiellokaladresse.Aus(adresse); + } + } + + /// Bemerkung bis zur Tabelle der Terminwünsche (kann über mehrere Zeilen gehen). + private void BemerkungLesen(string erste) + { + var teile = new List { erste }; + while (index < zeilen.Count && !zeilen[index].Text.StartsWith("Terminwünsche ", StringComparison.Ordinal) + && !Mannschaftskopf().IsMatch(zeilen[index].Text)) + { + teile.Add(zeilen[index].Text); + index++; + } + + aktuell!.Bemerkung = string.Join(' ', teile.Where(t => t.Length > 0)).Trim(); + } + + private void Abschliessen() + { + if (aktuell is not null) + { + fertig.Add(aktuell.Meldung()); + aktuell = null; + } + + spalten = null; + } + } + + private sealed class TerminEntwurf(DateTime zeitpunkt, string halle, string max, string koppel) + { + public DateTime Zeitpunkt { get; } = zeitpunkt; + + public string Halle { get; set; } = halle; + + public string Max { get; set; } = max; + + public string Koppel { get; set; } = koppel; + } + + private sealed class Entwurf(string verein, int nummer, string klasse) + { + public Dictionary Spiellokale { get; } = []; + + public List Termine { get; } = []; + + public List Sperrtermine { get; } = []; + + public string Bemerkung { get; set; } = string.Empty; + + public Vereinsmeldung Meldung() => new( + verein, + nummer, + klasse, + Spiellokale, + Bemerkung, + Termine.Select(t => new GemeldeterHeimtermin(t.Zeitpunkt, t.Halle, t.Max, t.Koppel)).ToList(), + Sperrtermine); + } +} diff --git a/src/AndiGenerator.Persistence/Terminwuensche/Textwort.cs b/src/AndiGenerator.Persistence/Terminwuensche/Textwort.cs new file mode 100644 index 0000000..6f2b434 --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/Textwort.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// Ein Wort einer PDF-Seite mit seiner linken Kante (in Punkt). +/// Abstand der linken Kante vom linken Seitenrand. +/// Das Wort. +public sealed record Textwort(double Links, string Text); diff --git a/src/AndiGenerator.Persistence/Terminwuensche/Textzeile.cs b/src/AndiGenerator.Persistence/Terminwuensche/Textzeile.cs new file mode 100644 index 0000000..10979d7 --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/Textzeile.cs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// Eine Zeile einer PDF-Seite: Wörter mit gleicher Höhe, von links nach rechts. +/// Seite ab 0. +/// Abstand der Grundlinie der Zeile vom oberen Seitenrand (in Punkt). +/// Die Wörter von links nach rechts. +public sealed record Textzeile(int Seite, double Oben, IReadOnlyList Woerter) +{ + /// Holt den Text der Zeile (Wörter mit Leerzeichen). + public string Text { get; } = string.Join(' ', Woerter.Select(w => w.Text)); + + /// Text der Wörter, deren linke Kante in einer Spalte liegt. + /// Linke Kante der Spalte. + /// Linke Kante der nächsten Spalte. + /// Die Wörter der Spalte mit Leerzeichen. + public string Spalte(double von, double bis) => + string.Join(' ', Woerter.Where(w => w.Links >= von - 1 && w.Links < bis - 1).Select(w => w.Text)); +} diff --git a/src/AndiGenerator.Persistence/Terminwuensche/Vereinsmeldung.cs b/src/AndiGenerator.Persistence/Terminwuensche/Vereinsmeldung.cs new file mode 100644 index 0000000..4dad250 --- /dev/null +++ b/src/AndiGenerator.Persistence/Terminwuensche/Vereinsmeldung.cs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Persistence.Aufstellungen; + +namespace AndiGenerator.Persistence.Terminwuensche; + +/// Meldung einer Mannschaft im click-TT-Bericht „Terminwünsche“ (ohne Kontaktdaten). +/// Name des Vereins, z. B. „TTC Wirges“. +/// Vereinsnummer aus click-TT (in der XML-Datei clubId). +/// Altersklasse mit Mannschaftsnummer, z. B. „Erwachsene II“ oder „Jugend 19“. +/// Spiellokale 1 bis 3 des Vereins (nur angegebene). +/// Bemerkung des Vereins (Freitext), leer wenn keine. +/// Terminwünsche der Vor- und Rückrunde. +/// Sperrtermine der Vor- und Rückrunde. +public sealed record Vereinsmeldung( + string Verein, + int VereinsNummer, + string Klasse, + IReadOnlyDictionary Spiellokale, + string Bemerkung, + IReadOnlyList Heimtermine, + IReadOnlyList Sperrtermine) +{ + /// Holt die Mannschaftsnummer aus der Klasse („Erwachsene II“ → 2, „Jugend 19“ → 1). + public int MannschaftsNummer => MannschaftsaufstellungenCsv.Nummer(Klasse); +} diff --git a/src/AndiGenerator.Persistence/Turniere/TurniermeldungenCsv.cs b/src/AndiGenerator.Persistence/Turniere/TurniermeldungenCsv.cs new file mode 100644 index 0000000..4c95385 --- /dev/null +++ b/src/AndiGenerator.Persistence/Turniere/TurniermeldungenCsv.cs @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text; +using AndiGenerator.Domain.Turniere; + +namespace AndiGenerator.Persistence.Turniere; + +/// +/// Liest die Meldungen einer Turnierrunde aus der CSV-Datei einer Online-Umfrage (z. B. Google Formulare): je Zeile eine +/// Meldung mit Mannschaft, je Sonntag einer Spalte „TT.MM.JJJJ“ (Antwort „möglicher Heimspieltag“ und/oder „möglicher +/// Auswärtsspieltag“ oder „können nicht“), Halle, „zwei Spiele parallel“ und Bemerkungen. Meldet eine Mannschaft +/// mehrfach, gilt die letzte Zeile. Falsch kodierte Umlaute („können“) werden repariert. +/// +public static class TurniermeldungenCsv +{ + /// Liest die Meldungen aus einer Datei. + /// Pfad der CSV-Datei. + /// Die Meldungen, je Mannschaft eine, in der Reihenfolge der ersten Meldung. + /// Spalten für Mannschaft oder Termine fehlen. + public static IReadOnlyList Laden(string pfad) => Lesen(TextLesen(File.ReadAllBytes(pfad))); + + /// Liest die Meldungen aus dem Text einer CSV-Datei. + /// Inhalt der Datei. + /// Die Meldungen, je Mannschaft eine, in der Reihenfolge der ersten Meldung. + /// Spalten für Mannschaft oder Termine fehlen. + public static IReadOnlyList Lesen(string text) + { + ArgumentNullException.ThrowIfNull(text); + List> zeilen = Zerlegen(Reparieren(text)); + if (zeilen.Count == 0) + { + throw new FormatException("Die Datei mit den Meldungen ist leer."); + } + + List kopf = zeilen[0]; + int mannschaft = kopf.FindIndex(k => k.Contains("Mannschaft", StringComparison.OrdinalIgnoreCase)); + int halle = kopf.FindIndex(k => k.Contains("Halle", StringComparison.OrdinalIgnoreCase) && !k.Contains("parallel", StringComparison.OrdinalIgnoreCase)); + int parallel = kopf.FindIndex(k => k.Contains("parallel", StringComparison.OrdinalIgnoreCase)); + int bemerkung = kopf.FindIndex(k => k.TrimStart().StartsWith("Bemerkung", StringComparison.OrdinalIgnoreCase)); + var tage = new List<(int Spalte, DateOnly Tag)>(); + for (int i = 0; i < kopf.Count; i++) + { + if (Datum(kopf[i]) is DateOnly tag) + { + tage.Add((i, tag)); + } + } + + if (mannschaft < 0 || tage.Count == 0) + { + throw new FormatException("In der Kopfzeile fehlen die Spalte mit der Mannschaft oder die Spalten mit den Terminen (TT.MM.JJJJ)."); + } + + var meldungen = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reihenfolge = new List(); + foreach (List zeile in zeilen.Skip(1)) + { + string name = Feld(zeile, mannschaft); + if (name.Length == 0) + { + continue; + } + + var bereitschaft = new Dictionary(); + foreach ((int spalte, DateOnly tag) in tage) + { + bereitschaft[tag] = Bereitschaft(Feld(zeile, spalte)); + } + + if (!meldungen.ContainsKey(name)) + { + reihenfolge.Add(name); + } + + meldungen[name] = new Turniermeldung( + name, + bereitschaft, + Feld(zeile, halle), + Feld(zeile, parallel).StartsWith("Ja", StringComparison.OrdinalIgnoreCase), + Feld(zeile, bemerkung)); + } + + return reihenfolge.Select(n => meldungen[n]).ToList(); + } + + /// Antwort einer Zelle: „Heimspieltag“ und „Auswärtsspieltag“ können beide vorkommen. + /// Inhalt der Zelle. + /// Die Bereitschaft. + public static Turnierbereitschaft Bereitschaft(string zelle) + { + ArgumentNullException.ThrowIfNull(zelle); + Turnierbereitschaft b = Turnierbereitschaft.None; + if (zelle.Contains("Heimspieltag", StringComparison.OrdinalIgnoreCase)) + { + b |= Turnierbereitschaft.Heim; + } + + if (zelle.Contains("Auswärtsspieltag", StringComparison.OrdinalIgnoreCase) + || zelle.Contains("Auswaertsspieltag", StringComparison.OrdinalIgnoreCase)) + { + b |= Turnierbereitschaft.Auswaerts; + } + + return b; + } + + /// UTF-8 mit oder ohne BOM; ohne gültiges UTF-8 Windows-1252. + private static string TextLesen(byte[] inhalt) + { + try + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(inhalt).TrimStart(''); + } + catch (DecoderFallbackException) + { + return Encoding.Latin1.GetString(inhalt); + } + } + + /// Repariert UTF-8, das einmal als Latin-1 gelesen wurde („können“ → „können“). + private static string Reparieren(string text) + { + if (!text.Contains('Ã', StringComparison.Ordinal) || text.Any(c => c > 'ÿ')) + { + return text; + } + + try + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(Encoding.Latin1.GetBytes(text)); + } + catch (DecoderFallbackException) + { + return text; + } + } + + /// CSV nach RFC 4180: Komma als Trenner, Felder in Anführungszeichen dürfen Kommas und Zeilenumbrüche enthalten. + private static List> Zerlegen(string text) + { + var zeilen = new List>(); + var zeile = new List(); + var feld = new StringBuilder(); + int i = 0; + while (i < text.Length) + { + char c = text[i]; + i++; + if (c == '"') + { + i = ZitatLesen(text, i, feld) + 1; + } + else if (c == ',') + { + zeile.Add(feld.ToString()); + feld.Clear(); + } + else if (c is '\r' or '\n') + { + if (c == '\r' && i < text.Length && text[i] == '\n') + { + i++; + } + + zeile.Add(feld.ToString()); + feld.Clear(); + Abschliessen(zeilen, zeile); + zeile = []; + } + else + { + feld.Append(c); + } + } + + zeile.Add(feld.ToString()); + Abschliessen(zeilen, zeile); + return zeilen; + } + + /// Liest ein Feld in Anführungszeichen ab (doppelte Anführungszeichen stehen für eines). + /// Index des schließenden Anführungszeichens. + private static int ZitatLesen(string text, int start, StringBuilder feld) + { + int i = start; + while (i < text.Length) + { + if (text[i] != '"') + { + feld.Append(text[i]); + } + else if (i + 1 < text.Length && text[i + 1] == '"') + { + feld.Append('"'); + i++; + } + else + { + return i; + } + + i++; + } + + return i; + } + + private static void Abschliessen(List> zeilen, List zeile) + { + if (zeile.Exists(f => f.Trim().Length > 0)) + { + zeilen.Add(zeile); + } + } + + private static string Feld(List zeile, int spalte) => spalte >= 0 && spalte < zeile.Count ? zeile[spalte].Trim() : string.Empty; + + /// Datum am Anfang einer Kopfzelle, z. B. „ 15.11.2026 (Volkstrauertag)“. + private static DateOnly? Datum(string kopf) + { + string text = kopf.Trim(); + return text.Length >= 10 + && DateOnly.TryParseExact(text[..10], "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly tag) + ? tag + : null; + } +} diff --git a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs index 2d7cdc7..43c27d6 100644 --- a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs +++ b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs @@ -19,6 +19,7 @@ public sealed class EinrichtungViewModel : ObservableObject private readonly Func> uebernehmen; private readonly Func seiteErzeugen; private readonly Func abschliessen; + private readonly Func abbrechen; private Einrichtungsschritt aktiv; private string meldung = string.Empty; @@ -28,33 +29,38 @@ public sealed class EinrichtungViewModel : ObservableObject /// Übernimmt einen Punkt; false, wenn er nicht übernommen werden konnte. /// Erzeugt die eingebettete Seite eines Datenpunkts mit dem aktuellen Stand der Daten. /// Schließt die Seite; generiert wird danach erst mit „Generierung starten“. + /// Bricht die Einrichtung ab und schließt die Staffel (schon übernommene Punkte bleiben gespeichert). public EinrichtungViewModel( string titel, IReadOnlyList schritte, Func> uebernehmen, Func seiteErzeugen, - Func abschliessen) + Func abschliessen, + Func abbrechen) { ArgumentNullException.ThrowIfNull(schritte); ArgumentOutOfRangeException.ThrowIfZero(schritte.Count); ArgumentNullException.ThrowIfNull(uebernehmen); ArgumentNullException.ThrowIfNull(seiteErzeugen); ArgumentNullException.ThrowIfNull(abschliessen); + ArgumentNullException.ThrowIfNull(abbrechen); Titel = titel; Schritte = schritte; this.uebernehmen = uebernehmen; this.seiteErzeugen = seiteErzeugen; this.abschliessen = abschliessen; + this.abbrechen = abbrechen; foreach (Einrichtungsschritt schritt in schritte) { schritt.Gewaehlt += (_, _) => Aktiv = schritt; } - aktiv = schritte[0]; + aktiv = schritte.FirstOrDefault(s => s.Zustand == Schrittzustand.Offen) ?? schritte[0]; Zeigen(aktiv); UebernehmenCommand = new AsyncRelayCommand(UebernehmenAsync); UeberspringenCommand = new RelayCommand(Ueberspringen); AbschliessenCommand = new AsyncRelayCommand(AbschliessenAsync); + AbbrechenCommand = new AsyncRelayCommand(() => this.abbrechen()); } /// Holt die Überschrift. @@ -120,6 +126,9 @@ private set /// public IAsyncRelayCommand AbschliessenCommand { get; } + /// Holt den Befehl „Abbrechen“: schließt die Staffel; schon übernommene Punkte bleiben gespeichert. + public IAsyncRelayCommand AbbrechenCommand { get; } + private int Bearbeitet => Schritte.Count(s => s.Zustand != Schrittzustand.Offen); private void Zeigen(Einrichtungsschritt schritt) @@ -140,7 +149,8 @@ private async Task UebernehmenAsync() return; } - Abhaken(schritt, Schrittzustand.Erledigt); + // Ein Punkt mit mehreren Teilen (z. B. zwei Dateien) bleibt offen, bis alle erledigt sind. + Abhaken(schritt, (schritt.IstVollstaendig?.Invoke() ?? true) ? Schrittzustand.Erledigt : Schrittzustand.Offen); } private async Task AbschliessenAsync() diff --git a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs index b3e1ed2..dd84859 100644 --- a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs +++ b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs @@ -71,6 +71,12 @@ public Einrichtungsschritt(int nummer, Erststarthinweis hinweis) /// Holt einen Wert, der angibt, ob der Schritt eine Entscheidung ist. public bool IstEntscheidung => Optionen.Count > 0; + /// + /// Holt oder setzt, ob ein Punkt nach dem Übernehmen vollständig erledigt ist (z. B. erst, wenn beide Dateien geladen + /// sind); ohne Angabe ist er es immer. + /// + public Func? IstVollstaendig { get; init; } + /// Holt den Punkt des Erststart-Assistenten oder null bei Entscheidungen. public Erststartpunkt? Punkt { get; } diff --git a/src/AndiGenerator.Presentation/Gegnerzeile.cs b/src/AndiGenerator.Presentation/Gegnerzeile.cs new file mode 100644 index 0000000..a040512 --- /dev/null +++ b/src/AndiGenerator.Presentation/Gegnerzeile.cs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Application; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// +/// Gegner auf der Seite „Wochenende (60-km-Regel)“: Häkchen wie im Original, dazu die Entfernung der Hallen (nicht im +/// Original) und ein Hinweis, wenn die Häkchen nicht zur Entfernung passen. +/// +public sealed class Gegnerzeile : ObservableObject +{ + private readonly Action geaendert; + private readonly Hallenentfernung entfernung; + private bool gewaehlt; + + /// Initialisiert die Zeile. + /// Gegner und Entfernung. + /// Wird nach einer Änderung des Häkchens aufgerufen. + public Gegnerzeile(Hallenentfernung entfernung, Action geaendert) + { + ArgumentNullException.ThrowIfNull(entfernung); + this.entfernung = entfernung; + this.geaendert = geaendert; + } + + /// Holt den Namen des Gegners. + public string Name => entfernung.Gegner; + + /// Holt die Entfernung als Text, z. B. „72 km“, oder einen leeren Text ohne Adressen. + public string Entfernung => entfernung.Luftlinie is double km + ? km.ToString("0", CultureInfo.InvariantCulture) + " km" + : string.Empty; + + /// Holt den Hinweis zur Zeile oder einen leeren Text. + public string Hinweis => (entfernung.Luftlinie, Gewaehlt, entfernung.HeimUnterDerWoche) switch + { + (double km, false, true) when km > Datenbearbeitung.Wochenspielgrenze => "weit, Heimspiele auch unter der Woche", + (double km, true, _) when km <= Datenbearbeitung.Wochenspielgrenze => "nah", + _ => string.Empty, + }; + + /// Holt einen Wert, der angibt, ob ein Hinweis angezeigt wird. + public bool HatHinweis => Hinweis.Length > 0; + + /// Holt oder setzt das Häkchen. + public bool Gewaehlt + { + get => gewaehlt; + set + { + if (SetProperty(ref gewaehlt, value)) + { + HinweisMelden(); + geaendert(); + } + } + } + + /// Setzt das Häkchen ohne Änderungsmeldung (Laden, Standard). + /// Neuer Zustand. + internal void Setzen(bool wert) + { + if (SetProperty(ref gewaehlt, wert, nameof(Gewaehlt))) + { + HinweisMelden(); + } + } + + private void HinweisMelden() + { + OnPropertyChanged(nameof(Hinweis)); + OnPropertyChanged(nameof(HatHinweis)); + } +} diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs index 4796d48..662ce5b 100644 --- a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs @@ -149,13 +149,13 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster Layout = layout; AlleAnsichtenAktualisieren(); - OeffnenCommand = new AsyncRelayCommand(OeffnenAsync); - NeuCommand = new AsyncRelayCommand(NeueDateiAsync); + OeffnenCommand = new AsyncRelayCommand(OeffnenAsync, () => !ZeigtEinrichtung); + NeuCommand = new AsyncRelayCommand(NeueDateiAsync, () => !ZeigtEinrichtung); SchliessenCommand = new AsyncRelayCommand(SchliessenAsync, () => IstGeoeffnet); GenerierungCommand = new AsyncRelayCommand(GenerierungUmschaltenAsync, () => IstGeoeffnet); KostenoptimierungCommand = new RelayCommand(() => VerfahrenWaehlen(false), () => IstGeoeffnet); AutomodusCommand = new RelayCommand(() => VerfahrenWaehlen(true), () => IstGeoeffnet); - ZuletztOeffnenCommand = new AsyncRelayCommand(ZuletztOeffnenAsync); + ZuletztOeffnenCommand = new AsyncRelayCommand(ZuletztOeffnenAsync, _ => !ZeigtEinrichtung); ZuletztEntfernenCommand = new RelayCommand(ZuletztEntfernen); ZoomGroesserCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Groesser()); ZoomKleinerCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Kleiner()); @@ -261,13 +261,16 @@ private set { if (SetProperty(ref einrichtung, value)) { - // Während der Einrichtung sind Ansichten, Spielplandaten, Einstellungen und Drucken links gesperrt. + // Während der Einrichtung sind Öffnen, Neu, Ansichten, Spielplandaten, Einstellungen und Drucken links gesperrt. OnPropertyChanged(nameof(ZeigtEinrichtung)); OnPropertyChanged(nameof(ZeigtArbeitsbereich)); OnPropertyChanged(nameof(AktiveAnsicht)); EinstellungenCommand.NotifyCanExecuteChanged(); DatenCommand.NotifyCanExecuteChanged(); DruckenCommand.NotifyCanExecuteChanged(); + OeffnenCommand.NotifyCanExecuteChanged(); + NeuCommand.NotifyCanExecuteChanged(); + ZuletztOeffnenCommand.NotifyCanExecuteChanged(); AnsichtsbefehleAktualisieren(); } } @@ -1217,6 +1220,21 @@ private async Task StaffelOeffnenAsync(string pfad) private EinrichtungViewModel? EinrichtungErstellen(Plansitzung neu) { var schritte = new List(); + + // Als erster Punkt weitere Exporte aus click-TT (nicht im Original); schon übernommen: als erledigt markiert. + // Erledigt erst, wenn Mannschaftsmeldung und Terminwünsche übernommen sind – auch über mehrere Sitzungen. + static bool BeideGeladen(Datenbearbeitung d) => d.MannschaftsmeldungVermerk.Length > 0 && d.TerminwunschVermerk.Length > 0; + var zusatzschritt = new Einrichtungsschritt(1, "Daten aus click-TT ergänzen", ZusatzdatenSeiteViewModel.Einrichtungstext, [], []) + { + IstVollstaendig = () => BeideGeladen(neu.DatenBearbeiten()), + }; + if (BeideGeladen(neu.DatenBearbeiten())) + { + zusatzschritt.Zustand = Schrittzustand.Erledigt; + } + + schritte.Add(zusatzschritt); + IReadOnlyList abweichungen = neu.AbweichungenVomStandard(); if (abweichungen.Count > 0) { @@ -1272,14 +1290,21 @@ private async Task StaffelOeffnenAsync(string pfad) schritte.AddRange(neu.Erststarthinweise().Select((h, i) => new Einrichtungsschritt(bisher + i + 1, h))); } - return schritte.Count == 0 + return schritte.TrueForAll(s => s.Zustand == Schrittzustand.Erledigt) ? null : new EinrichtungViewModel( neu.Staffel.Name + " einrichten", schritte, schritt => EinrichtungsschrittUebernehmenAsync(neu, schritt, stufenschritt, rundenschritt, vorschlag), - schritt => SeiteErzeugen(neu, schritt), - EinrichtungAbschliessen); + schritt => ReferenceEquals(schritt, zusatzschritt) ? ZusatzseiteErzeugen(neu) : SeiteErzeugen(neu, schritt), + EinrichtungAbschliessen, + SchliessenAsync); + } + + private DatenDialogViewModel ZusatzseiteErzeugen(Plansitzung s) + { + Datenbearbeitung bearbeitung = s.DatenBearbeiten(); + return new DatenDialogViewModel(bearbeitung, [new ZusatzdatenSeiteViewModel(bearbeitung, oberflaeche)]); } private DatenDialogViewModel? SeiteErzeugen(Plansitzung s, Einrichtungsschritt schritt) diff --git a/src/AndiGenerator.Presentation/Kachelwert.cs b/src/AndiGenerator.Presentation/Kachelwert.cs new file mode 100644 index 0000000..32f010b --- /dev/null +++ b/src/AndiGenerator.Presentation/Kachelwert.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Wert in einer Kachel, z. B. „261“ mit „Heimtermine erhalten die Spiellokal-Nummer“. +/// Der Wert. +/// Die Beschriftung. +public sealed record Kachelwert(string Wert, string Text); diff --git a/src/AndiGenerator.Presentation/Staerkezeile.cs b/src/AndiGenerator.Presentation/Staerkezeile.cs new file mode 100644 index 0000000..59070f7 --- /dev/null +++ b/src/AndiGenerator.Presentation/Staerkezeile.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Eine Zeile des Setzlisten-Vorschlags aus den Mannschaftsaufstellungen. +/// Platz, z. B. „3.“. +/// Name der Mannschaft. +/// Schnitt der gewerteten Stammspieler, z. B. „Ø 1983“, oder „keine Aufstellung“. +/// Zahl der Stammspieler, z. B. „5 Stammspieler“. +public sealed record Staerkezeile(string PlatzText, string Mannschaft, string SchnittText, string SpielerText); diff --git a/src/AndiGenerator.Presentation/UeberViewModel.cs b/src/AndiGenerator.Presentation/UeberViewModel.cs index 456ba1d..62801bd 100644 --- a/src/AndiGenerator.Presentation/UeberViewModel.cs +++ b/src/AndiGenerator.Presentation/UeberViewModel.cs @@ -88,8 +88,9 @@ public UeberViewModel(IOberflaeche oberflaeche, string version, string? basis) /// Holt den Hinweis auf die verwendeten Bibliotheken. public static string Drittbibliotheken => - "Verwendet Avalonia, Dock, CommunityToolkit.Mvvm, SkiaSharp und Velopack (jeweils MIT-Lizenz) sowie die Schriften IBM Plex Sans und Mono " - + "(SIL Open Font License 1.1); Einzelheiten in THIRD-PARTY-NOTICES.md."; + "Verwendet Avalonia, Dock, CommunityToolkit.Mvvm, SkiaSharp und Velopack (jeweils MIT-Lizenz), PdfPig (Apache-2.0) sowie die " + + "Schriften IBM Plex Sans und Mono (SIL Open Font License 1.1). Postleitzahlen-Daten: GeoNames (geonames.org), CC BY 4.0, " + + "gekürzt. Einzelheiten in THIRD-PARTY-NOTICES.md."; /// Holt die Programmversion. public string Version { get; } diff --git a/src/AndiGenerator.Presentation/Vereinskarte.cs b/src/AndiGenerator.Presentation/Vereinskarte.cs new file mode 100644 index 0000000..83e137d --- /dev/null +++ b/src/AndiGenerator.Presentation/Vereinskarte.cs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Application; + +namespace AndiGenerator.Presentation; + +/// Eine Mannschaft im Abgleich mit dem Bericht „Terminwünsche“: Spiellokale, Bemerkung, Unterschiede. +/// Name der Mannschaft. +/// Die Mannschaft steht im Bericht. +/// Spiellokale des Vereins. +/// Bemerkung des Vereins, leer wenn keine. +/// Unterschiede zu den Spielplandaten. +public sealed record Vereinskarte( + string Mannschaft, + bool Gefunden, + IReadOnlyList Spiellokale, + string Bemerkung, + IReadOnlyList Abweichungen) +{ + /// Holt einen Wert, der angibt, ob es eine Bemerkung gibt. + public bool HatBemerkung => Bemerkung.Length > 0; + + /// Holt einen Wert, der angibt, ob es Unterschiede gibt. + public bool HatAbweichungen => Abweichungen.Count > 0; + + /// Holt die Marke rechts im Kopf, z. B. „3 Unterschiede“, „nicht im Bericht“ oder „stimmt überein“. + public string Marke => (Gefunden, Abweichungen.Count) switch + { + (false, _) => "nicht im Bericht", + (true, 0) => "stimmt überein", + (true, 1) => "1 Unterschied", + _ => Abweichungen.Count.ToString(CultureInfo.InvariantCulture) + " Unterschiede", + }; +} diff --git a/src/AndiGenerator.Presentation/WochenendeDetailViewModel.cs b/src/AndiGenerator.Presentation/WochenendeDetailViewModel.cs index 72f4713..41511ad 100644 --- a/src/AndiGenerator.Presentation/WochenendeDetailViewModel.cs +++ b/src/AndiGenerator.Presentation/WochenendeDetailViewModel.cs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; namespace AndiGenerator.Presentation; @@ -21,12 +22,37 @@ public WochenendeDetailViewModel(Datenbearbeitung bearbeitung, string mannschaft : base(bearbeitung, "60km Regel") { this.mannschaft = mannschaft; - Gegner = bearbeitung.Mannschaftsnamen().Where(n => n != mannschaft).Select(n => new AuswahlZeile(n, false, AenderungMelden)).ToList(); + IReadOnlyList entfernungen = bearbeitung.Hallenentfernungen(mannschaft); + Gegner = entfernungen.Select(e => new Gegnerzeile(e, AenderungMelden)).ToList(); + HatEntfernungen = Gegner.Any(g => g.Entfernung.Length > 0); + List bekannt = entfernungen.Where(e => e.Luftlinie is not null).ToList(); + Gesamt = bekannt.Sum(e => e.Luftlinie ?? 0).ToString("0", CultureInfo.InvariantCulture) + " km"; + GesamtText = bekannt.Count == Gegner.Count + ? string.Create(CultureInfo.InvariantCulture, $"Gesamt zu allen {bekannt.Count} Gegnern (je einfache Strecke)") + : string.Create(CultureInfo.InvariantCulture, $"Gesamt zu {bekannt.Count} von {Gegner.Count} Gegnern (je einfache Strecke; ohne Adresse: {string.Join(", ", Gegner.Where(g => g.Entfernung.Length == 0).Select(g => g.Name))})"); Laden(); } + /// Holt die Erklärung zu Entfernungen und Hinweisen. + public static string Entfernungserklaerung => + "Entfernung in Luftlinie zwischen den Hallen. „weit“: über 60 km ohne Häkchen, obwohl der Gegner auch unter der " + + "Woche Heimspiele hat. „nah“: Häkchen bei höchstens 60 km. Die Wettspielordnung legt kein Maß fest – nur Hinweise."; + + /// Holt den Hinweis, woher die Entfernungen kommen. + public static string OhneEntfernungen => + "Entfernungen erscheinen, wenn die Terminwünsche (PDF) aus click-TT übernommen sind – in der Einrichtung unter „Daten aus click-TT ergänzen“."; + /// Holt alle anderen Mannschaften mit Häkchen. - public IReadOnlyList Gegner { get; } + public IReadOnlyList Gegner { get; } + + /// Holt einen Wert, der angibt, ob Entfernungen bekannt sind (Hallenadressen aus den Terminwünschen). + public bool HatEntfernungen { get; } + + /// Holt die Summe der Entfernungen zu allen Gegnern mit bekannter Halle, z. B. „612 km“. + public string Gesamt { get; } + + /// Holt die Beschriftung der Summe (wie viele Gegner, einfache Strecke). + public string GesamtText { get; } /// public override bool IstStandard => @@ -51,7 +77,7 @@ public override void Standard() private void Anzeigen(IReadOnlyList gewaehlt) { - foreach (AuswahlZeile g in Gegner) + foreach (Gegnerzeile g in Gegner) { g.Setzen(gewaehlt.Contains(g.Name, StringComparer.OrdinalIgnoreCase)); } diff --git a/src/AndiGenerator.Presentation/Wochenspielzeile.cs b/src/AndiGenerator.Presentation/Wochenspielzeile.cs new file mode 100644 index 0000000..e6a2072 --- /dev/null +++ b/src/AndiGenerator.Presentation/Wochenspielzeile.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Zeile der Prüfung „Wochenspiele“ (60-km-Regel, nicht im Original). +/// Mannschaft, die anreist. +/// Gastgeber. +/// Luftlinie als Text, z. B. „72 km“. +public sealed record Wochenspielzeile(string Gast, string Heim, string Entfernung); diff --git a/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs b/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs new file mode 100644 index 0000000..5365fdf --- /dev/null +++ b/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs @@ -0,0 +1,511 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Collections.ObjectModel; +using System.Globalization; +using AndiGenerator.Application; +using CommunityToolkit.Mvvm.Input; + +namespace AndiGenerator.Presentation; + +/// +/// Seite „Daten aus click-TT“ (erster Punkt der Einrichtung beim ersten Öffnen, nicht im Original): weitere Exporte aus +/// click-TT, die nicht in der Terminmeldung (XML) stehen: die Mannschaftsmeldung (CSV) als Vorschlag für die Setzliste +/// aus den TTR-Werten der Stammspieler und der Bericht „Terminwünsche“ (PDF) für Spiellokal-Nummern, Adressen und +/// Bemerkungen der Vereine. +/// +public sealed class ZusatzdatenSeiteViewModel : DatenSeiteViewModel +{ + private readonly IOberflaeche oberflaeche; + private readonly List gesetzt = []; + private Setzlistenvorschlag? vorschlag; + private Terminwunschabgleich? abgleich; + private string pdfDatei = string.Empty; + private string datei = string.Empty; + private string fehler = string.Empty; + private bool alsSetzliste = true; + private decimal? grenze = (decimal)Datenbearbeitung.Wochenspielgrenze; + private string wochenspielText = string.Empty; + private string wochenspielStatus = string.Empty; + private bool hatWochentagsgastgeber; + private bool hatWochenspielpruefung; + private Wochenspielpruefung? pruefung; + private string protokollKopf = string.Empty; + + /// Initialisiert die Seite. + /// Arbeitskopie. + /// Dienste der Oberfläche (Dateiauswahl). + public ZusatzdatenSeiteViewModel(Datenbearbeitung bearbeitung, IOberflaeche oberflaeche) + : base(bearbeitung, "Daten aus click-TT") + { + ArgumentNullException.ThrowIfNull(oberflaeche); + this.oberflaeche = oberflaeche; + AufstellungenLadenCommand = new AsyncRelayCommand(AufstellungenLadenAsync); + TerminwuenscheLadenCommand = new AsyncRelayCommand(TerminwuenscheLadenAsync); + SperrenSetzenCommand = new AsyncRelayCommand(SperrenSetzenAsync, () => HatFehlendeSperren); + WochenspielePruefen(); + } + + /// Holt den Text des Einrichtungspunkts. + public static string Einrichtungstext => + "Die Terminmeldung aus click-TT (XML) enthält nicht alles, was für die Planung nützlich ist. Weitere Exporte " + + "derselben Staffel können Sie hier ergänzen – jeder ist freiwillig."; + + /// Holt die Erklärung zur Mannschaftsmeldung. + public static string AufstellungsErklaerung => + "Mannschaftsmeldung (CSV): In click-TT unter den Downloads der Staffel „Mannschaftsmeldung Vorrunde (csv)“. Aus den " + + "TTR-Werten der Stammspieler jeder Mannschaft entsteht ein Vorschlag für die Setzliste – die stärkste Mannschaft " + + "zuerst. Gelesen werden nur Mannschaft, Rang, Status und TTR-Wert, keine Namen."; + + /// Holt die Erklärung zum Bericht „Terminwünsche“. + public static string TerminwunschErklaerung => + "Terminwünsche (PDF): In click-TT unter den Downloads der Staffel „Terminmeldungen (pdf)“. Der Bericht nennt die " + + "Spiellokale 1 bis 3 jedes Vereins mit Adresse. Daraus setzt das Programm bei jedem Heimtermin die Nummer des " + + "Spiellokals (für den Export nach click-TT), merkt sich die Adressen und zeigt die Bemerkungen der Vereine sowie " + + "Unterschiede zu den Spielplandaten. Termine und Sperren werden nicht geändert; Kontaktdaten werden nicht gelesen."; + + /// Holt die Erklärung zur Prüfung der Wochenspiele. + public static string WochenspielErklaerung => + "Ein Verein kann Auswärtsspiele unter der Woche bei weit entfernten Gegnern sperren (Spielplandaten, Seite " + + "„Wochenende (60-km-Regel)“); die Generierung legt diese Spiele dann aufs Wochenende. Die Wettspielordnung legt " + + "dafür kein Maß fest. Diese Prüfung vergleicht die Sperren mit der Luftlinie zwischen den Hallen – sofort hier, " + + "sobald die Hallenadressen bekannt sind, und erneut bei jeder Änderung der Grenze. Sie gibt nur Hinweise und " + + "ändert von selbst nichts. Eine Sperre entscheidet nur dort, wo der Gastgeber Heimtermine unter der Woche und am " + + "Wochenende hat (unter der Woche: Montag bis Donnerstag, Freitag nur, wenn er in den Einstellungen nicht zum " + + "Wochenende zählt) – nur diese Paare werden geprüft. Fehlende Sperren können Sie mit einer Rückfrage für alle " + + "Treffer setzen; das Protokoll zeigt danach, welche."; + + /// Holt den Befehl „Terminwünsche laden…“. + public IAsyncRelayCommand TerminwuenscheLadenCommand { get; } + + /// Holt einen Wert, der angibt, ob ein Bericht „Terminwünsche“ geladen ist. + public bool HatTerminwuensche => abgleich is not null; + + /// Holt die Erfolgsmeldung nach dem Laden der Terminwünsche (hervorgehoben unter dem Knopf) oder einen leeren Text. + public string TerminwunschErgebnis + { + get + { + if (abgleich is null) + { + return string.Empty; + } + + List m = abgleich.Mannschaften.ToList(); + static string Anzahl(int n, string eins, string mehr) => n.ToString(CultureInfo.InvariantCulture) + " " + (n == 1 ? eins : mehr); + return string.Create( + CultureInfo.InvariantCulture, + $"„{pdfDatei}“ geladen: {m.Count(x => x.Gefunden)} von {m.Count} Mannschaften gefunden, {Anzahl(m.Sum(x => x.Abweichungen.Count), "Unterschied", "Unterschiede")}, {Anzahl(m.Count(x => x.Bemerkung.Length > 0), "Bemerkung", "Bemerkungen")}. Einzelheiten und die Prüfung der Wochenspiele stehen darunter."); + } + } + + /// Holt die Kennzahlen des Abgleichs (Mannschaften, gesetzte Nummern, Bemerkungen, Unterschiede). + public ObservableCollection Kennzahlen { get; } = []; + + /// Holt je Mannschaft Spiellokale, Bemerkung und Unterschiede. + public ObservableCollection Vereine { get; } = []; + + /// Holt den Namen der geladenen PDF-Datei. + public string TerminwunschDatei => pdfDatei; + + /// Holt den Vermerk, welche Mannschaftsmeldung schon übernommen wurde, oder einen leeren Text. + public string AufstellungsVermerk => Vermerk(Bearbeitung.MannschaftsmeldungVermerk); + + /// Holt den Vermerk, welcher Bericht „Terminwünsche“ schon übernommen wurde, oder einen leeren Text. + public string TerminwunschVermerk => Vermerk(Bearbeitung.TerminwunschVermerk); + + /// Holt oder setzt die Grenze in Kilometern Luftlinie (Vorgabe 60). + public decimal? Grenze + { + get => grenze; + set + { + if (SetProperty(ref grenze, value)) + { + WochenspielePruefen(); + } + } + } + + /// Holt einen Wert, der angibt, ob Hallenadressen bekannt sind und die Prüfung angezeigt wird. + public bool HatWochenspielpruefung + { + get => hatWochenspielpruefung; + private set => SetProperty(ref hatWochenspielpruefung, value); + } + + /// Holt das Ergebnis in einem Satz, z. B. „Keine Mannschaft hat Heimtermine unter der Woche …“. + public string WochenspielStatus + { + get => wochenspielStatus; + private set => SetProperty(ref wochenspielStatus, value); + } + + /// Holt einen Wert, der angibt, ob es Gastgeber mit Heimterminen unter der Woche gibt (Grenze und Listen). + public bool HatWochentagsgastgeber + { + get => hatWochentagsgastgeber; + private set => SetProperty(ref hatWochentagsgastgeber, value); + } + + /// Holt die Kennzahlen der Prüfung als Kacheln. + public ObservableCollection WochenspielKennzahlen { get; } = []; + + /// Holt die Zusatzhinweise der Prüfung (pauschale Sperren, fehlende Adressen) oder einen leeren Text. + public string WochenspielText + { + get => wochenspielText; + private set => SetProperty(ref wochenspielText, value); + } + + /// Holt die Paare über der Grenze ohne Sperre („Sperre fehlt vermutlich“). + public ObservableCollection FehlendeSperren { get; } = []; + + /// Holt die Paare bis zur Grenze mit Sperre („Sperre vermutlich unnötig“). + public ObservableCollection UnnoetigeSperren { get; } = []; + + /// Holt den Befehl „Fehlende Sperren setzen…“ (eine Rückfrage für alle Treffer). + public IAsyncRelayCommand SperrenSetzenCommand { get; } + + /// Holt das Protokoll der gesetzten Sperren, je Sperre eine Zeile. + public ObservableCollection SperrenProtokoll { get; } = []; + + /// Holt die Kopfzeile des Protokolls (wann, bei welcher Grenze, wie viele) oder einen leeren Text. + public string SperrenProtokollKopf + { + get => protokollKopf; + private set + { + if (SetProperty(ref protokollKopf, value)) + { + OnPropertyChanged(nameof(HatSperrenProtokoll)); + } + } + } + + /// Holt einen Wert, der angibt, ob Sperren gesetzt wurden. + public bool HatSperrenProtokoll => SperrenProtokollKopf.Length > 0; + + /// Holt einen Wert, der angibt, ob Sperren vermutlich fehlen. + public bool HatFehlendeSperren => FehlendeSperren.Count > 0; + + /// Holt einen Wert, der angibt, ob Sperren vermutlich unnötig sind. + public bool HatUnnoetigeSperren => UnnoetigeSperren.Count > 0; + + /// Holt den Befehl „Mannschaftsmeldung laden…“. + public IAsyncRelayCommand AufstellungenLadenCommand { get; } + + /// Holt die Zeilen des Vorschlags. + public ObservableCollection Staerken { get; } = []; + + /// Holt einen Wert, der angibt, ob eine Mannschaftsmeldung geladen ist. + public bool HatAufstellungen => vorschlag is not null; + + /// Holt die Erfolgsmeldung nach dem Laden der Mannschaftsmeldung (hervorgehoben unter dem Knopf) oder einen leeren Text. + public string AufstellungsErgebnis => vorschlag is null + ? string.Empty + : string.Create(CultureInfo.InvariantCulture, $"„{datei}“ geladen: Vorschlag für die Setzliste mit {vorschlag.Reihenfolge.Count} Mannschaften, die stärkste zuerst – siehe darunter."); + + /// Holt die Beschreibung des Vorschlags, z. B. welche Datei und wie gewertet wurde. + public string AufstellungsHinweis + { + get + { + if (vorschlag is null) + { + return string.Empty; + } + + string text = string.Create(CultureInfo.InvariantCulture, $"{datei}: Schnitt der besten {vorschlag.GewerteteSpieler} Stammspieler ohne Reservespieler."); + return vorschlag.OhneAufstellung.Count == 0 ? text : text + " Ohne Aufstellung: " + string.Join(", ", vorschlag.OhneAufstellung) + "."; + } + } + + /// Holt die Fehlermeldung beim Laden oder einen leeren Text. + public string Fehler + { + get => fehler; + private set + { + if (SetProperty(ref fehler, value)) + { + OnPropertyChanged(nameof(HatFehler)); + } + } + } + + /// Holt einen Wert, der angibt, ob eine Fehlermeldung angezeigt wird. + public bool HatFehler => Fehler.Length > 0; + + /// Holt oder setzt einen Wert, der angibt, ob der Vorschlag als Setzliste übernommen (und diese aktiviert) wird. + public bool AlsSetzliste + { + get => alsSetzliste; + set + { + if (SetProperty(ref alsSetzliste, value)) + { + AenderungMelden(); + } + } + } + + /// + public override bool IstStandard => vorschlag is null && abgleich is null && !HatSperrenProtokoll; + + /// + public override void Laden() + { + // Der geladene Vorschlag bleibt beim Wechsel der Seite erhalten; aus der Arbeitskopie ist nichts zu lesen. + } + + /// + public override void Speichern() + { + if (abgleich is not null) + { + Bearbeitung.TerminwuenscheUebernehmen(abgleich, pdfDatei); + } + + if (vorschlag is null) + { + return; + } + + Bearbeitung.MannschaftsmeldungVermerken(datei); + if (AlsSetzliste) + { + Bearbeitung.SetzlisteSpeichern(new Setzlistendaten(true, vorschlag.Reihenfolge.Select(m => m.Mannschaft).ToList())); + } + } + + /// + public override void Standard() + { + if (gesetzt.Count > 0) + { + Bearbeitung.WochenspielSperrenEntfernen(gesetzt); + gesetzt.Clear(); + SperrenProtokoll.Clear(); + SperrenProtokollKopf = string.Empty; + } + + Vorschlagen(null, string.Empty); + Abgleichen(null, string.Empty); + } + + /// Liest einen Bericht „Terminwünsche“ und zeigt den Abgleich; Fehler erscheinen in . + /// Pfad der PDF-Datei. + public void TerminwuenscheLaden(string pfad) + { + Terminwunschabgleich neu; + try + { + neu = Bearbeitung.TerminwuenscheAbgleichen(pfad); + } + catch (Exception ex) when (ex is FormatException or IOException or UnauthorizedAccessException) + { + Fehler = ex.Message; + return; + } + + TerminwuenscheAnzeigen(neu, Path.GetFileName(pfad)); + } + + /// Zeigt einen fertigen Abgleich an, als wäre der Bericht gerade geladen worden (auch für die Bildschirmfotos). + /// Der Abgleich. + /// Name der PDF-Datei für die Anzeige. + public void TerminwuenscheAnzeigen(Terminwunschabgleich neu, string dateiname) + { + ArgumentNullException.ThrowIfNull(neu); + Fehler = string.Empty; + Abgleichen(neu, dateiname); + AenderungMelden(); + } + + /// Liest eine Mannschaftsmeldung und zeigt den Vorschlag; Fehler erscheinen in . + /// Pfad der CSV-Datei. + public void AufstellungenLaden(string pfad) + { + Setzlistenvorschlag neu; + try + { + neu = Bearbeitung.SetzlisteAusMannschaftsmeldung(pfad); + } + catch (Exception ex) when (ex is FormatException or IOException or UnauthorizedAccessException) + { + Fehler = ex.Message; + return; + } + + if (neu.OhneAufstellung.Count == neu.Reihenfolge.Count) + { + Fehler = "In der Datei steht keine Mannschaft dieser Staffel."; + return; + } + + Fehler = string.Empty; + Vorschlagen(neu, Path.GetFileName(pfad)); + AenderungMelden(); + } + + private static string Vermerk(string vermerk) => vermerk.Length == 0 ? string.Empty : "Bereits übernommen: " + vermerk + "."; + + private static string Protokollzeile(Wochenspielbefund b) => + string.Create(CultureInfo.InvariantCulture, $"{b.Gast} spielt bei {b.Heim} nur am Wochenende ({b.Luftlinie:0} km)"); + + private async Task AufstellungenLadenAsync() + { + string? pfad = await oberflaeche.DateiOeffnenAsync("Mannschaftsmeldung aus click-TT laden", [new Dateifilter("CSV-Dateien", ["*.csv"])]); + if (pfad is not null) + { + AufstellungenLaden(pfad); + } + } + + private async Task TerminwuenscheLadenAsync() + { + string? pfad = await oberflaeche.DateiOeffnenAsync("Terminwünsche aus click-TT laden", [new Dateifilter("PDF-Dateien", ["*.pdf"])]); + if (pfad is not null) + { + TerminwuenscheLaden(pfad); + } + } + + private void Abgleichen(Terminwunschabgleich? neu, string dateiname) + { + abgleich = neu; + pdfDatei = dateiname; + Kennzahlen.Clear(); + Vereine.Clear(); + if (neu is not null) + { + string Zahl(int n) => n.ToString(CultureInfo.InvariantCulture); + List m = neu.Mannschaften.ToList(); + Kennzahlen.Add(new Kachelwert(Zahl(m.Count(x => x.Gefunden)) + " / " + Zahl(m.Count), "Mannschaften im Bericht gefunden")); + Kennzahlen.Add(new Kachelwert(Zahl(neu.NummernGesetzt), "Heimtermine erhalten die Spiellokal-Nummer")); + Kennzahlen.Add(new Kachelwert(Zahl(m.Count(x => x.Bemerkung.Length > 0)), "Bemerkungen der Vereine")); + Kennzahlen.Add(new Kachelwert(Zahl(m.Sum(x => x.Abweichungen.Count)), "Unterschiede zu den Spielplandaten")); + + // Mannschaften mit Bemerkung oder Unterschieden zuerst, sonst in der Reihenfolge der Staffel. + foreach (Mannschaftsabgleich a in m.OrderBy(x => x.Bemerkung.Length > 0 || x.Abweichungen.Count > 0 || !x.Gefunden ? 0 : 1)) + { + Vereine.Add(new Vereinskarte(a.Mannschaft, a.Gefunden, a.Spiellokale, a.Bemerkung, a.Abweichungen)); + } + } + + OnPropertyChanged(nameof(TerminwunschErgebnis)); + OnPropertyChanged(nameof(HatTerminwuensche)); + OnPropertyChanged(nameof(TerminwunschDatei)); + OnPropertyChanged(nameof(IstStandard)); + WochenspielePruefen(); + } + + private async Task SperrenSetzenAsync() + { + if (pruefung is not Wochenspielpruefung p) + { + return; + } + + List fehlend = p.Befunde.Where(b => b.Art == Wochenspielhinweis.SperreFehlt).ToList(); + if (fehlend.Count == 0) + { + return; + } + + string grenzeText = p.Grenze.ToString("0", CultureInfo.InvariantCulture); + string text = string.Create( + CultureInfo.InvariantCulture, + $"Bei {fehlend.Count} Paaren liegen die Hallen weiter als {grenzeText} km auseinander, der Gastgeber hat Heimtermine unter der Woche und am Wochenende, und der Gast hat ihn noch nicht gesperrt. Sollen diese Spiele nur am Wochenende stattfinden?"); + if (!await oberflaeche.EntscheidenAsync("Fehlende Sperren setzen", text, fehlend.Select(Protokollzeile).ToList(), "Sperren setzen", "Abbrechen")) + { + return; + } + + IReadOnlyList neu = Bearbeitung.WochenspielSperrenSetzen(fehlend); + gesetzt.AddRange(neu); + foreach (Wochenspielbefund b in neu) + { + SperrenProtokoll.Add(Protokollzeile(b)); + } + + SperrenProtokollKopf = string.Create( + CultureInfo.InvariantCulture, + $"{DateTime.Now:dd.MM.yyyy HH:mm}: {gesetzt.Count} Sperren gesetzt (Grenze {grenzeText} km). Sie werden mit der Staffel gespeichert, sobald Sie die Seite übernehmen."); + WochenspielePruefen(); + AenderungMelden(); + } + + private void WochenspielePruefen() + { + double km = (double)(Grenze ?? (decimal)Datenbearbeitung.Wochenspielgrenze); + Wochenspielpruefung p = Bearbeitung.WochenspielePruefen(km, abgleich?.Meldungen ?? []); + pruefung = p; + FehlendeSperren.Clear(); + UnnoetigeSperren.Clear(); + foreach (Wochenspielbefund b in p.Befunde) + { + var zeile = new Wochenspielzeile(b.Gast, b.Heim, b.Luftlinie.ToString("0", CultureInfo.InvariantCulture) + " km"); + (b.Art == Wochenspielhinweis.SperreFehlt ? FehlendeSperren : UnnoetigeSperren).Add(zeile); + } + + string Zahl(int n) => n.ToString(CultureInfo.InvariantCulture); + WochenspielKennzahlen.Clear(); + WochenspielKennzahlen.Add(new Kachelwert(Zahl(p.Wochentagsgastgeber.Count), "Gastgeber mit Heimterminen unter der Woche und am Wochenende")); + WochenspielKennzahlen.Add(new Kachelwert(Zahl(p.GeprueftePaare), "Paare geprüft (Gast → Gastgeber)")); + WochenspielKennzahlen.Add(new Kachelwert(Zahl(FehlendeSperren.Count), "Sperre fehlt vermutlich")); + WochenspielKennzahlen.Add(new Kachelwert(Zahl(UnnoetigeSperren.Count), "Sperre vermutlich unnötig")); + HatWochentagsgastgeber = p.Wochentagsgastgeber.Count > 0; + WochenspielStatus = (p.Wochentagsgastgeber.Count, p.Befunde.Count) switch + { + (0, _) => "In dieser Staffel hat keine Mannschaft Heimtermine sowohl unter der Woche als auch am Wochenende " + + "gemeldet – wann gespielt wird, steht damit schon fest. Die Sperren wirken sich nicht aus; es gibt nichts zu prüfen.", + (_, 0) => "Keine Auffälligkeiten: Alle Sperren passen zur Grenze.", + _ => "Gastgeber mit Heimterminen unter der Woche und am Wochenende: " + string.Join(", ", p.Wochentagsgastgeber) + ".", + }; + + var text = new List(); + if (p.PauschalGesperrt.Count > 0) + { + text.Add("Alle Gegner gesperrt (Vereinswunsch, nicht bewertet): " + string.Join(", ", p.PauschalGesperrt) + "."); + } + + if (p.OhneLage.Count > 0) + { + text.Add("Ohne Hallenadresse: " + string.Join(", ", p.OhneLage) + "."); + } + + OnPropertyChanged(nameof(HatFehlendeSperren)); + OnPropertyChanged(nameof(HatUnnoetigeSperren)); + SperrenSetzenCommand.NotifyCanExecuteChanged(); + WochenspielText = string.Join(" ", text); + HatWochenspielpruefung = p.OhneLage.Count < Bearbeitung.Mannschaftsnamen().Count; + } + + private void Vorschlagen(Setzlistenvorschlag? neu, string dateiname) + { + vorschlag = neu; + datei = dateiname; + Staerken.Clear(); + if (neu is not null) + { + int platz = 0; + foreach (Mannschaftsstaerke m in neu.Reihenfolge) + { + platz++; + Staerken.Add(new Staerkezeile( + platz.ToString(CultureInfo.InvariantCulture) + ".", + m.Mannschaft, + m.HatAufstellung ? "Ø " + m.Schnitt.ToString("0", CultureInfo.InvariantCulture) : "keine Aufstellung", + m.HatAufstellung ? m.Stammspieler.ToString(CultureInfo.InvariantCulture) + " Stammspieler" : string.Empty)); + } + } + + OnPropertyChanged(nameof(AufstellungsErgebnis)); + OnPropertyChanged(nameof(HatAufstellungen)); + OnPropertyChanged(nameof(AufstellungsHinweis)); + OnPropertyChanged(nameof(IstStandard)); + } +} diff --git a/src/AndiGenerator.UI/EinrichtungAnsicht.axaml b/src/AndiGenerator.UI/EinrichtungAnsicht.axaml index 7eaed31..de8564e 100644 --- a/src/AndiGenerator.UI/EinrichtungAnsicht.axaml +++ b/src/AndiGenerator.UI/EinrichtungAnsicht.axaml @@ -90,6 +90,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +