Erlaube das Hinzufügen von Teilnehmern ohne Klasse und normalisiere die Anzahl der Gruppen auf mindestens 1 in der Turnierverwaltung

This commit is contained in:
Torsten Schulz (local)
2025-12-13 12:25:17 +01:00
parent 0c28b12978
commit e83bc250a8
5 changed files with 316 additions and 83 deletions

View File

@@ -85,74 +85,113 @@ class TournamentService {
}
// 3. Teilnehmer hinzufügen (kein Duplikat) - klassengebunden
async addParticipant(userToken, clubId, classId, participantId) {
async addParticipant(userToken, clubId, classId, participantId, tournamentId = null) {
await checkAccess(userToken, clubId);
if (!classId) {
throw new Error('Klasse ist erforderlich');
// Normalize case: the third parameter may be either a classId or a tournamentId
if (classId) {
const maybeClass = await TournamentClass.findByPk(classId);
if (!maybeClass) {
const maybeTournament = await Tournament.findByPk(classId);
if (maybeTournament) {
// caller passed tournamentId in this slot
tournamentId = classId;
classId = null;
} else {
throw new Error('Klasse nicht gefunden');
}
}
}
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
throw new Error('Klasse nicht gefunden');
// If classId is provided (and resolved to a valid class), do class-specific validations and add
if (classId) {
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) throw new Error('Klasse nicht gefunden');
const tournament = await Tournament.findByPk(tournamentClass.tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Prüfe Geschlecht: Lade Mitglied
const member = await Member.findByPk(participantId);
if (!member) {
throw new Error('Mitglied nicht gefunden');
}
const memberGender = member.gender || 'unknown';
// Validierung: Geschlecht muss zur Klasse passen
if (tournamentClass.gender) {
if (tournamentClass.gender === 'male' && memberGender !== 'male') {
throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen');
}
if (tournamentClass.gender === 'female' && memberGender !== 'female') {
throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen');
}
if (tournamentClass.gender === 'mixed' && memberGender === 'unknown') {
throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen');
}
}
// Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=)
if (tournamentClass.minBirthYear && member.birthDate) {
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
let birthYear = null;
if (member.birthDate.includes('-')) {
birthYear = parseInt(member.birthDate.split('-')[0]);
} else if (member.birthDate.includes('.')) {
const parts = member.birthDate.split('.');
if (parts.length === 3) {
birthYear = parseInt(parts[2]);
}
}
if (birthYear && !isNaN(birthYear)) {
if (birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
}
}
// Prüfe, ob Teilnehmer bereits in dieser Klasse ist
const exists = await TournamentMember.findOne({ where: { classId, clubMemberId: participantId } });
if (exists) {
throw new Error('Teilnehmer bereits hinzugefügt');
}
await TournamentMember.create({
tournamentId: tournamentClass.tournamentId,
classId,
clubMemberId: participantId,
groupId: null
});
return;
}
const tournament = await Tournament.findByPk(tournamentClass.tournamentId);
// If classId is not provided, allow adding participant to the tournament without class
// tournamentId must be provided in this case
if (!tournamentId) {
throw new Error('Klasse oder Turnier-ID ist erforderlich');
}
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Prüfe Geschlecht: Lade Mitglied
// Verify member exists
const member = await Member.findByPk(participantId);
if (!member) {
throw new Error('Mitglied nicht gefunden');
}
const memberGender = member.gender || 'unknown';
// Validierung: Geschlecht muss zur Klasse passen
if (tournamentClass.gender) {
if (tournamentClass.gender === 'male' && memberGender !== 'male') {
throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen');
}
if (tournamentClass.gender === 'female' && memberGender !== 'female') {
throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen');
}
if (tournamentClass.gender === 'mixed' && memberGender === 'unknown') {
throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen');
}
// mixed erlaubt alle Geschlechter (male, female, diverse)
}
// Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=)
if (tournamentClass.minBirthYear && member.birthDate) {
// Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY)
let birthYear = null;
if (member.birthDate.includes('-')) {
// Format: YYYY-MM-DD
birthYear = parseInt(member.birthDate.split('-')[0]);
} else if (member.birthDate.includes('.')) {
// Format: DD.MM.YYYY
const parts = member.birthDate.split('.');
if (parts.length === 3) {
birthYear = parseInt(parts[2]);
}
}
if (birthYear && !isNaN(birthYear)) {
// Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear
if (birthYear < tournamentClass.minBirthYear) {
throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`);
}
}
}
// Prüfe, ob Teilnehmer bereits in dieser Klasse ist
const exists = await TournamentMember.findOne({
where: { classId, clubMemberId: participantId }
// Prüfe, ob Teilnehmer bereits im Turnier (ohne Klasse) ist
const existsNoClass = await TournamentMember.findOne({
where: { tournamentId, classId: null, clubMemberId: participantId }
});
if (exists) {
throw new Error('Teilnehmer bereits in dieser Klasse hinzugefügt');
if (existsNoClass) {
throw new Error('Teilnehmer bereits hinzugefügt');
}
await TournamentMember.create({
tournamentId: tournamentClass.tournamentId,
classId,
tournamentId,
classId: null,
clubMemberId: participantId,
groupId: null
});
@@ -197,22 +236,54 @@ class TournamentService {
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const desired = numberOfGroups !== null ? numberOfGroups : tournament.numberOfGroups;
// Semantik:
// - null/undefined: nimm den im Turnier gespeicherten Wert
// - 0: UI-Default -> normalisiere auf mindestens 1
// - <0 oder nicht-integer: Fehler
let desired = tournament.numberOfGroups;
if (numberOfGroups !== null && numberOfGroups !== undefined) {
const n = Number(numberOfGroups);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error('numberOfGroups muss eine ganze Zahl >= 0 sein');
}
desired = Math.max(1, n);
}
// DEBUG: Gruppen-Planung sichtbar machen (temporär)
console.log('[tournamentService.createGroups] input:', {
tournamentId,
clubId,
numberOfGroups,
tournamentStoredNumberOfGroups: tournament.numberOfGroups,
desired,
});
const existing = await TournamentGroup.findAll({
where: {
tournamentId,
classId: null // Nur Gruppen ohne Klasse
}
});
console.log('[tournamentService.createGroups] existing groups (classId=null):', existing.map(g => g.id));
// zu viele Gruppen löschen
if (existing.length > desired) {
const toRemove = existing.slice(desired);
console.log('[tournamentService.createGroups] removing groups:', toRemove.map(g => g.id));
await Promise.all(toRemove.map(g => g.destroy()));
}
// fehlende Gruppen anlegen
let createdCount = 0;
for (let i = existing.length; i < desired; i++) {
await TournamentGroup.create({ tournamentId, classId: null });
createdCount++;
}
const finalGroups = await TournamentGroup.findAll({
where: { tournamentId, classId: null },
order: [['id', 'ASC']],
});
console.log('[tournamentService.createGroups] createdCount:', createdCount, 'finalCount:', finalGroups.length, 'finalIds:', finalGroups.map(g => g.id));
}
async createGroupsPerClass(userToken, clubId, tournamentId, groupsPerClass) {
@@ -433,7 +504,9 @@ class TournamentService {
}).length
);
for (let round = 0; round < 10; round++) {
// Wichtig: Gruppengrößen dürfen dabei NICHT aus dem Gleichgewicht geraten.
// Deshalb nur 1:1 Swaps (seeded <-> non-seeded) zwischen Gruppen.
for (let round = 0; round < 20; round++) {
let changed = false;
let maxSeededIdx = 0;
let maxSeededCount = seededCounts[0];
@@ -459,15 +532,30 @@ class TournamentService {
const memberData = memberDataMap.get(item.memberId);
return memberData && memberData.seeded;
});
if (seededInMax.length > 0) {
const itemToMove = seededInMax[0];
groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(item =>
!(item.type === 'member' && item.memberId === itemToMove.memberId)
const nonSeededInMin = groupAssignments[minSeededIdx].filter(item => {
if (item.type !== 'member') return false;
const memberData = memberDataMap.get(item.memberId);
return memberData && !memberData.seeded;
});
if (seededInMax.length > 0 && nonSeededInMin.length > 0) {
const seededToSwap = seededInMax[0];
const nonSeededToSwap = nonSeededInMin[0];
// Swap: seeded wandert in Min, non-seeded wandert in Max
groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].map(item =>
(item.type === 'member' && item.memberId === seededToSwap.memberId) ? nonSeededToSwap : item
);
seededCounts[maxSeededIdx]--;
groupAssignments[minSeededIdx].push(itemToMove);
seededCounts[minSeededIdx]++;
groupAssignments[minSeededIdx] = groupAssignments[minSeededIdx].map(item =>
(item.type === 'member' && item.memberId === nonSeededToSwap.memberId) ? seededToSwap : item
);
seededCounts[maxSeededIdx] -= 1;
seededCounts[minSeededIdx] += 1;
changed = true;
} else {
// Ohne passenden Swap-Kandidaten können wir die Seeded-Verteilung nicht weiter verbessern.
break;
}
}