diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 5a3cf9e7..8ee41127 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -124,6 +124,86 @@ function compareKnockoutEntrants(a, b) { const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { + async createRoundRobinMatchesForGroups(tournamentId, groups, stageId = null) { + for (const group of groups) { + const internalMembers = await TournamentMember.findAll({ where: { groupId: group.id } }); + const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId: group.id } }); + + const allGroupMembers = [ + ...internalMembers.map(m => ({ id: m.id, key: `internal-${m.id}` })), + ...externalMembers.map(m => ({ id: m.id, key: `external-${m.id}` })), + ]; + if (allGroupMembers.length < 2) continue; + + const memberMap = new Map(allGroupMembers.map(m => [m.key, m])); + const rounds = this.generateRoundRobinSchedule(allGroupMembers.map(m => ({ id: m.key }))); + for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) { + for (const [p1Key, p2Key] of rounds[roundIndex]) { + const p1 = memberMap.get(p1Key); + const p2 = memberMap.get(p2Key); + if (!p1 || !p2) continue; + await TournamentMatch.create({ + tournamentId, + stageId, + groupId: group.id, + round: 'group', + player1Id: p1.id, + player2Id: p2.id, + groupRound: roundIndex + 1, + classId: group.classId, + }); + } + } + } + } + + async assignEntrantsToStageGroups(tournamentId, items, groups, classId) { + if (!groups.length || !items.length) return; + const orderedItems = [...items].sort(compareKnockoutEntrants); + const assignments = groups.map(() => []); + orderedItems.forEach((item, index) => { + assignments[index % groups.length].push(item); + }); + + for (let groupIndex = 0; groupIndex < assignments.length; groupIndex++) { + const group = groups[groupIndex]; + for (const item of assignments[groupIndex]) { + if (item.isExternal) { + const original = await ExternalTournamentParticipant.findByPk(Number(item.id)); + if (!original) continue; + await ExternalTournamentParticipant.create({ + tournamentId, + groupId: group.id, + firstName: original.firstName, + lastName: original.lastName, + club: original.club, + email: original.email, + address: original.address, + birthDate: original.birthDate, + gender: original.gender, + seeded: original.seeded || false, + classId, + outOfCompetition: original.outOfCompetition || false, + gaveUp: original.gaveUp || false, + }); + } else { + const clubMemberId = Number(item.clubMemberId ?? item.member?.id ?? item.id); + const member = Number.isFinite(clubMemberId) ? await Member.findByPk(clubMemberId) : null; + if (!member) continue; + await TournamentMember.create({ + tournamentId, + groupId: group.id, + clubMemberId, + seeded: item.seeded || false, + classId, + outOfCompetition: item.outOfCompetition || false, + gaveUp: item.gaveUp || false, + }); + } + } + } + } + // -------- Multi-Stage (Runden) V1 -------- async getTournamentStages(userToken, clubId, tournamentId) { await checkAccess(userToken, clubId); @@ -257,24 +337,35 @@ class TournamentService { } const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration'); - const pools = Array.isArray(config.pools) ? config.pools : []; + let pools = Array.isArray(config.pools) ? config.pools : []; if (pools.length === 0) { - const keys = Object.keys(config); - throw new Error( - `Advancement-Konfiguration ist leer (keine Pools). ` + - `advancementId=${adv.id}, fromStageId=${fromStage.id}, toStageId=${toStage.id}, ` + - `configKeys=${keys.join(',') || '(none)'}` - ); + const advancingCount = Math.max(1, Number(toStage.advancingPerGroup || t.advancingPerGroup || 2)); + const fromPlaces = Array.from({ length: advancingCount }, (_, index) => index + 1); + pools = [{ + fromPlaces, + target: toStage.type === 'knockout' + ? { type: 'knockout', singleField: true } + : { type: 'groups', groupCount: Math.max(1, Number(toStage.numberOfGroups || 1)) }, + }]; } - // Cleanup Stage2 + // Cleanup Zielrunde inklusive dort duplizierter Teilnehmer. + const existingTargetGroups = await TournamentGroup.findAll({ where: { tournamentId, stageId: toStage.id } }); + const existingTargetGroupIds = existingTargetGroups.map(g => g.id); await TournamentMatch.destroy({ where: { tournamentId, stageId: toStage.id } }); + if (existingTargetGroupIds.length > 0) { + await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } }); + await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } }); + } await TournamentGroup.destroy({ where: { tournamentId, stageId: toStage.id } }); // Stage1 Gruppen mit Teilnehmern (Ranking aus existierender Logik) const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId); - const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id)); - if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden'); + const relevantStage1Groups = stage1Groups.filter(g => { + if (Number(fromStage.index) === 1 && g.stageId == null) return true; + return Number(g.stageId) === Number(fromStage.id); + }); + if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in der Ausgangsrunde gefunden'); const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, @@ -370,10 +461,8 @@ class TournamentService { [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - // WICHTIG: Um bestehende Gruppen (Stage 1) nicht zu überschreiben, - // nehmen wir hier KEINE groupId-Zuordnung für Teilnehmer vor. - // Die Stage-2-Gruppen werden angelegt und durch Matches befüllt, - // ohne den groupId-Wert der Teilnehmer zu verändern. + await this.assignEntrantsToStageGroups(tournamentId, shuffled, poolGroups, poolClassId); + await this.createRoundRobinMatchesForGroups(tournamentId, poolGroups, toStage.id); createdGroups.push(...poolGroups); } else if (target.type === 'knockout') { @@ -406,22 +495,6 @@ class TournamentService { const bracketSize = nextPowerOfTwo(entrants.length); const byes = bracketSize - entrants.length; const roundName = getRoundName(bracketSize); - if (wantsThirdPlace && bracketSize >= 4) { - // Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt. - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId: poolClassId, - groupRound: null, - round: THIRD_PLACE_ROUND, - player1Id: null, - player2Id: null, - isFinished: false, - isActive: false, - result: null, - }); - } // Erzeuge zunächst BYE‑Matches für die ersten 'byes' Teilnehmer let remaining = [...entrants]; if (byes > 0) { @@ -540,22 +613,6 @@ class TournamentService { const reversedSecondHalf = [...secondHalf].reverse(); // Umgekehrte Reihenfolge für Pairing const roundName = getRoundName(bracketSize); - if (thirdPlace && bracketSize >= 4) { - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId, - groupRound: null, - round: THIRD_PLACE_ROUND, - player1Id: null, - player2Id: null, - isFinished: false, - isActive: false, - result: null, - }); - } - // BYE‑Matches zuerst (für die ersten Teilnehmer, wenn nötig) if (byes > 0) { // Die besten Spieler bekommen BYEs @@ -769,7 +826,12 @@ class TournamentService { if (classId !== null) { whereClause.classId = classId; } - return await TournamentMember.findAll({ + const stageGroups = await TournamentGroup.findAll({ + where: { tournamentId, stageId: { [Op.ne]: null } }, + attributes: ['id'], + }); + const stageGroupIds = new Set(stageGroups.map(g => Number(g.id))); + const participants = await TournamentMember.findAll({ where: whereClause, include: [{ model: Member, @@ -778,6 +840,7 @@ class TournamentService { }], order: [[{ model: Member, as: 'member' }, 'firstName', 'ASC']] }); + return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId))); } // 5. Modus setzen (Gruppen / KO‑Runde) @@ -1545,6 +1608,7 @@ class TournamentService { // Filtere nur KO‑Container‑Gruppen heraus: erkenne sie über Stage‑Typen. // Vorrunden‑Gruppen bleiben sichtbar, auch wenn (noch) keine Matches existieren. const stages = await TournamentStage.findAll({ where: { tournamentId } }); + const stageById = new Map(stages.map(s => [Number(s.id), s])); const knockoutStageIds = new Set(stages.filter(s => s.type === 'knockout').map(s => s.id)); groups = groups.filter(g => !(g.stageId && knockoutStageIds.has(g.stageId))); @@ -1634,6 +1698,7 @@ class TournamentService { for (const tm of g.tournamentGroupMembers || []) { stats[tm.id] = { id: tm.id, + clubMemberId: tm.clubMemberId, name: `${tm.member.firstName} ${tm.member.lastName}`, seeded: tm.seeded || false, isExternal: false, @@ -1870,10 +1935,22 @@ class TournamentService { }; }); + const stage = g.stageId ? stageById.get(Number(g.stageId)) : null; + const sameStageGroups = classGroups.filter(other => (other.stageId ?? null) === (g.stageId ?? null)); + const stageGroupIndex = sameStageGroups.findIndex(other => Number(other.id) === Number(g.id)); + const groupNumber = stageGroupIndex >= 0 ? stageGroupIndex + 1 : idx + 1; + const stageLabel = stage + ? (stage.name || (Number(stage.index) === 3 ? 'Endrunde' : 'Zwischenrunde')) + : null; + result.push({ groupId: g.id, classId: g.classId, - groupNumber: idx + 1, // Nummer innerhalb der Klasse + stageId: g.stageId ?? null, + stageIndex: stage?.index ?? null, + stageName: stageLabel, + groupNumber, + groupLabel: stageLabel ? `${stageLabel}-Gruppe ${groupNumber}` : null, participants: participantsWithPosition }); }); @@ -2150,9 +2227,20 @@ class TournamentService { } }); - if (!existingThirdPlace) { + if (existingThirdPlace) { + if ((existingThirdPlace.player1Id == null || existingThirdPlace.player1Id === 0) && (existingThirdPlace.player2Id == null || existingThirdPlace.player2Id === 0)) { + existingThirdPlace.player1Id = losers[0]; + existingThirdPlace.player2Id = losers[1]; + existingThirdPlace.stageId = existingThirdPlace.stageId || match.stageId || null; + existingThirdPlace.groupId = existingThirdPlace.groupId || match.groupId || null; + existingThirdPlace.isActive = true; + await existingThirdPlace.save(); + } + } else { await TournamentMatch.create({ tournamentId, + stageId: match.stageId || null, + groupId: match.groupId || null, round: THIRD_PLACE_ROUND, player1Id: losers[0], player2Id: losers[1], @@ -2170,7 +2258,7 @@ class TournamentService { // Gilt nur für neue Stage-KO-Matches (stageId + groupId gesetzt). // Wichtig: Halbfinal-Rundennamen können Suffixe haben (z.B. "Halbfinale (3)"). if (match.stageId && match.groupId && match.round && String(match.round).includes('Halbfinale')) { - const thirdPlaceMatch = await TournamentMatch.findOne({ + let thirdPlaceMatch = await TournamentMatch.findOne({ where: { tournamentId, stageId: match.stageId, @@ -2179,25 +2267,37 @@ class TournamentService { } }); - if (thirdPlaceMatch) { - const semifinals = await TournamentMatch.findAll({ - where: { - tournamentId, - stageId: match.stageId, - groupId: match.groupId, - } - }); + const semifinals = await TournamentMatch.findAll({ + where: { + tournamentId, + stageId: match.stageId, + groupId: match.groupId, + } + }); - const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale')); + const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale')); + const finishedSemis = semiMatches.filter(m => m.isFinished && m.result); + if (finishedSemis.length >= 2) { + const losers = finishedSemis + .map(getLoserId) + .filter(id => Number.isFinite(id) && id > 0); - const finishedSemis = semiMatches.filter(m => m.isFinished && m.result); - if (finishedSemis.length >= 2) { - const losers = finishedSemis - .map(getLoserId) - .filter(id => Number.isFinite(id) && id > 0); - - // Nur setzen, wenn wir genau 2 Verlierer haben und das Match noch "leer" ist. - if (losers.length === 2 && (thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) { + if (losers.length === 2) { + if (!thirdPlaceMatch) { + thirdPlaceMatch = await TournamentMatch.create({ + tournamentId, + stageId: match.stageId, + groupId: match.groupId, + classId: match.classId, + groupRound: null, + round: THIRD_PLACE_ROUND, + player1Id: losers[0], + player2Id: losers[1], + isFinished: false, + isActive: true, + result: null, + }); + } else if ((thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) { thirdPlaceMatch.player1Id = losers[0]; thirdPlaceMatch.player2Id = losers[1]; thirdPlaceMatch.isActive = true; @@ -2208,14 +2308,17 @@ class TournamentService { } // Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind + const roundWhere = { tournamentId, round: match.round, classId: match.classId }; + if (match.stageId) roundWhere.stageId = match.stageId; + if (match.groupId) roundWhere.groupId = match.groupId; const allFinished = await TournamentMatch.count({ - where: { tournamentId, round: match.round, isFinished: false, classId: match.classId } + where: { ...roundWhere, isFinished: false } }) === 0; if (allFinished) { - // Lade alle Matches dieser Runde UND Klasse + // Lade alle Matches dieser Runde UND Klasse innerhalb desselben KO-Felds const sameRound = await TournamentMatch.findAll({ - where: { tournamentId, round: match.round, classId: match.classId } + where: roundWhere }); // Gruppiere nach Klasse @@ -2262,6 +2365,8 @@ class TournamentService { } await TournamentMatch.create({ tournamentId, + stageId: match.stageId || null, + groupId: match.groupId || null, round: nextName, player1Id: p1, player2Id: p2, @@ -3053,15 +3158,50 @@ class TournamentService { async resetKnockout(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); - // lösche alle Matches außer Gruppenphase - const where = { + + const stages = await TournamentStage.findAll({ where: { tournamentId } }); + const nonPreliminaryStageIds = stages + .filter(stage => Number(stage.index) !== 1) + .map(stage => Number(stage.id)); + + const stageGroupWhere = { tournamentId }; + if (nonPreliminaryStageIds.length > 0) { + stageGroupWhere.stageId = { [Op.in]: nonPreliminaryStageIds }; + } else { + stageGroupWhere.stageId = { [Op.ne]: null }; + } + if (classId != null) { + stageGroupWhere.classId = Number(classId); + } + + const stageGroups = await TournamentGroup.findAll({ where: stageGroupWhere }); + const stageGroupIds = stageGroups.map(group => Number(group.id)); + + const knockoutWhere = { tournamentId, round: { [Op.ne]: "group" } }; if (classId != null) { - where.classId = Number(classId); + knockoutWhere.classId = Number(classId); + } + await TournamentMatch.destroy({ where: knockoutWhere }); + + if (stageGroupIds.length > 0) { + const stageMatchWhere = { + tournamentId, + [Op.or]: [ + { groupId: { [Op.in]: stageGroupIds } }, + { stageId: { [Op.in]: nonPreliminaryStageIds } }, + ], + }; + if (classId != null) { + stageMatchWhere.classId = Number(classId); + } + await TournamentMatch.destroy({ where: stageMatchWhere }); + await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } }); + await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } }); + await TournamentGroup.destroy({ where: { id: { [Op.in]: stageGroupIds } } }); } - await TournamentMatch.destroy({ where }); } // Externe Teilnehmer hinzufügen @@ -3143,10 +3283,16 @@ class TournamentService { if (classId !== null) { whereClause.classId = classId; } - return await ExternalTournamentParticipant.findAll({ + const stageGroups = await TournamentGroup.findAll({ + where: { tournamentId, stageId: { [Op.ne]: null } }, + attributes: ['id'], + }); + const stageGroupIds = new Set(stageGroups.map(g => Number(g.id))); + const participants = await ExternalTournamentParticipant.findAll({ where: whereClause, order: [['firstName', 'ASC'], ['lastName', 'ASC']] }); + return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId))); } // Externe Teilnehmer löschen diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index eedd2e50..50b1d9ce 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -563,16 +563,34 @@ export default { }) .filter(p => p.fromPlaces.length > 0); }, + defaultPoolsForTarget(targetType, groupCount = 1, thirdPlace = false) { + return [{ + fromPlaces: [1, 2], + target: targetType === 'knockout' + ? { type: 'knockout', singleField: true, thirdPlace } + : { type: 'groups', groupCount: Math.max(1, Number(groupCount || 1)) } + }]; + }, buildPayload() { - const pools12 = this.stageConfig.useIntermediateStage + let pools12 = this.stageConfig.useIntermediateStage ? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false) : []; - const poolsFinal = this.buildPoolsPayload( + if (this.stageConfig.useIntermediateStage && pools12.length === 0) { + pools12 = this.defaultPoolsForTarget(this.stageConfig.stage2Type, this.stageConfig.stage2GroupCount || 2, false); + } + let poolsFinal = this.buildPoolsPayload( this.stageConfig.poolsFinal, this.stageConfig.finalStageGroupCount || 1, true, this.stageConfig.finalStageThirdPlace === true ); + if (poolsFinal.length === 0) { + poolsFinal = this.defaultPoolsForTarget( + this.stageConfig.finalStageType, + this.stageConfig.finalStageGroupCount || 1, + this.stageConfig.finalStageThirdPlace === true + ); + } const stages = [ { index: 1, type: 'groups', name: 'Vorrunde' }, @@ -637,15 +655,6 @@ export default { try { const { stages, advancements } = this.buildPayload(); - // Validierung: Für jeden Übergang müssen Pools vorhanden sein - for (const adv of advancements) { - const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0; - if (!hasPools) { - const label = `${adv.fromStageIndex}→${adv.toStageIndex}`; - throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`); - } - } - const res = await apiClient.put('/tournament/stages', { clubId: Number(this.clubId), tournamentId: Number(this.tournamentId), @@ -684,13 +693,8 @@ export default { clubId: Number(this.clubId), tournamentId: Number(this.tournamentId) }; - if (from?.stageId && to?.stageId) { - payload.fromStageId = from.stageId; - payload.toStageId = to.stageId; - } else { - payload.fromStageIndex = Number(fromStageIndex); - payload.toStageIndex = Number(toStageIndex); - } + payload.fromStageIndex = Number(fromStageIndex); + payload.toStageIndex = Number(toStageIndex); const res = await apiClient.post('/tournament/stages/advance', payload); if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde'); diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 4f81ac19..005abc31 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -68,7 +68,7 @@
| {{ m.groupRound }} | - {{ getGroupClassName(m.groupId) }} - {{ $t('tournaments.groupNumber') }} {{ m.groupNumber }} + {{ getGroupClassName(m.groupId) }} - {{ m.groupLabel || ($t('tournaments.groupNumber') + ' ' + m.groupNumber) }} - {{ $t('tournaments.groupNumber') }} {{ m.groupNumber }} + {{ m.groupLabel || ($t('tournaments.groupNumber') + ' ' + m.groupNumber) }} |
@@ -125,12 +125,12 @@
|