feat(core+frontend+domain): add ZNS Bewerb parser and integrate start list feature

- **Parser Implementation:**
  - Introduced `ZnsBewerbParser` to parse n2-XXXXX.dat files and map B-Satz lines to the `ZnsBewerb` domain model.
  - Added test coverage for parsing B-Satz lines and edge cases in `ZnsParserTest`.

- **Frontend Integration:**
  - Integrated ZNS import functionality into the `BewerbeTabContent` for uploading and previewing Bewerb data before import.
  - Enhanced `BewerbViewModel` with state and intents for managing ZNS import, preview dialogs, and import confirmation.
  - Supported start list generation and added modal for previewing generated start lists.

- **Domain Services:**
  - Implemented `StartlistenService` to generate and calculate start times for start lists with respect to participant preferences.
  - Added extensive test coverage in `StartlistenServiceTest` to validate sorting, preferences, and time calculations.

- **UI Enhancements:**
  - Updated `Bewerbe` tab layout with search, filtering, and action buttons for ZNS import and start list generation.
  - Introduced dialogs for ZNS import previews and start list previews.
This commit is contained in:
2026-04-10 09:59:28 +02:00
parent a3007b01ee
commit 363aa80fe4
12 changed files with 622 additions and 25 deletions
@@ -0,0 +1,73 @@
package at.mocode.zns.parser
import at.mocode.core.utils.parser.FixedWidthLineReader
import kotlinx.datetime.LocalDate
/**
* Domänen-Modell für einen ZNS-Bewerb (B-Satz).
*/
data class ZnsBewerb(
val bewerbNummer: Int,
val abteilung: Int,
val name: String,
val klasse: String,
val kategorie: String,
val datum: LocalDate?
)
/**
* Spezialisierter Parser für B-Sätze aus der n2-XXXXX.dat Datei.
*/
object ZnsBewerbParser {
/**
* Parst eine Zeile aus der n2-XXXXX.dat Datei, sofern es sich um einen B-Satz handelt.
* Ein B-Satz beginnt an Stelle 1 mit einem Blank, gefolgt von der Bewerbnummer.
* ACHTUNG: Die Kopfzeile 'BBEWERBE' muss vorher ausgefiltert werden.
*/
fun parse(line: String): ZnsBewerb? {
// Ein valider B-Satz hat mindestens 52 Zeichen (bis zum Datum)
if (line.length < 52) return null
// Kopfzeilen oder andere Sätze ignorieren
if (line.startsWith("BBEWERBE") || line.startsWith("A") || line.startsWith("RREITERLISTE")) {
return null
}
val reader = FixedWidthLineReader(line)
// Stelle 1: ID (Blank)
val id = reader.getString(1, 1)
if (id.isNotBlank()) return null
// Stelle 2-3: Bewerbnummer (2-stellig)
// Stelle 61-63: Bewerbnummer (3-stellig) - bevorzugt verwenden, falls vorhanden
val bewerbNummer3 = reader.getIntOrNull(61, 3)
val bewerbNummer2 = reader.getIntOrNull(2, 2)
val finalBewerbNummer = bewerbNummer3 ?: bewerbNummer2 ?: return null
// Stelle 4: Abteilung
val abteilung = reader.getIntOrNull(4, 1) ?: 0
// Stelle 5-39: Bewerbname
val name = reader.getString(5, 35)
// Stelle 40-43: Klasse
val klasse = reader.getString(40, 4)
// Stelle 44-51: Kategorie
val kategorie = reader.getString(44, 8)
// Stelle 52-59: Datum (JJJJMMTT)
val datum = reader.getLocalDateOrNull(53, 8)
return ZnsBewerb(
bewerbNummer = finalBewerbNummer,
abteilung = abteilung,
name = name,
klasse = klasse,
kategorie = kategorie,
datum = datum
)
}
}
@@ -91,4 +91,21 @@ class ZnsParserTest {
assertEquals("Stöglehner Otto", funktionaer.name)
assertEquals(listOf("DPF", "DSGP", "SS*"), funktionaer.qualifikationen)
}
@Test
fun `parseBewerb should extract B-Satz correctly`() {
// ID(1) + BEWNR(2) + ABT(1) + NAME(35) + KLASSE(4) + KAT(8) + DATUM(8) + BEWNR3(3)
// 1 2 3 4 5 6
// 12345678901234567890123456789012345678901234567890123456789012
val line = " 010Standardspringprüfung L CSN-C 20260410001"
val bewerb = ZnsBewerbParser.parse(line)
assertNotNull(bewerb)
assertEquals(1, bewerb.bewerbNummer)
assertEquals(0, bewerb.abteilung)
assertEquals("Standardspringprüfung", bewerb.name)
assertEquals("L", bewerb.klasse)
assertEquals("CSN-C", bewerb.kategorie)
assertEquals("2026-04-10", bewerb.datum.toString())
}
}