diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index ecdff700..64ea2e43 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -216,20 +216,23 @@ export const getMatchPlayers = async (req, res) => { export const getTtrPreviewPlayers = async (req, res) => { try { const { authcode: userToken } = req.headers; - const match = await Match.findByPk(req.params.matchId); - if (!match) return res.status(404).json({ error: 'Spiel nicht gefunden' }); - await checkAccess(userToken, match.clubId); + const isStandalonePreview = req.params.matchId === 'standalone'; + const match = isStandalonePreview ? null : await Match.findByPk(req.params.matchId); + if (!isStandalonePreview && !match) return res.status(404).json({ error: 'Spiel nicht gefunden' }); + const clubId = isStandalonePreview ? Number(req.body?.clubId) : match.clubId; + if (!Number.isInteger(clubId) || clubId <= 0) return res.status(400).json({ error: 'Verein fehlt für die TTR-Vorschau' }); + await checkAccess(userToken, clubId); - // The product uses German match dates; do not roll the cache over at - // UTC midnight while a late-evening match is still in progress. const snapshotDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date()); - const [cachedRows] = await sequelize.query( - 'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1', - { replacements: { matchId: match.id, snapshotDate } } - ); - if (cachedRows.length) { - const payload = typeof cachedRows[0].payload === 'string' ? JSON.parse(cachedRows[0].payload) : cachedRows[0].payload; - return res.json({ ...payload, cached: true, snapshotDate }); + if (match) { + const [cachedRows] = await sequelize.query( + 'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1', + { replacements: { matchId: match.id, snapshotDate } } + ); + if (cachedRows.length) { + const payload = typeof cachedRows[0].payload === 'string' ? JSON.parse(cachedRows[0].payload) : cachedRows[0].payload; + return res.json({ ...payload, cached: true, snapshotDate }); + } } const sides = ['home', 'guest']; @@ -283,11 +286,13 @@ export const getTtrPreviewPlayers = async (req, res) => { const value = resolved.get(`${side}:${normalizeTtrName(playerName(player))}`); return { side, name: playerName(player), nuLigaPersonId: player?.nuLigaPersonId || null, ttr: value?.ttr ?? null, adjustmentConstant: value?.adjustmentConstant ?? null, source: value?.source || 'unavailable' }; }) }; - await sequelize.query( - `INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at) - VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`, - { replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } } - ); + if (match) { + await sequelize.query( + `INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at) + VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`, + { replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } } + ); + } return res.json({ ...payload, cached: false, snapshotDate }); } catch (error) { console.error('[getTtrPreviewPlayers]', error); diff --git a/backend/routes/matchRoutes.js b/backend/routes/matchRoutes.js index 940e53cb..c858e45b 100755 --- a/backend/routes/matchRoutes.js +++ b/backend/routes/matchRoutes.js @@ -17,6 +17,7 @@ router.get('/leagues/:clubId/table/:leagueId', authenticate, authorize('schedule router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, authorize('mytischtennis', 'write'), fetchLeagueTableFromMyTischtennis); router.get('/:clubId/players', authenticate, authorize('schedule', 'read'), getMatchPlayers); router.patch('/:matchId/players', authenticate, authorize('schedule', 'write'), updateMatchPlayers); +router.post('/ttr-preview/standalone', authenticate, authorize('schedule', 'read'), getTtrPreviewPlayers); router.post('/:matchId/ttr-preview', authenticate, authorize('schedule', 'read'), getTtrPreviewPlayers); router.get('/leagues/:clubId/stats/:leagueId', authenticate, authorize('schedule', 'read'), getPlayerMatchStats); diff --git a/frontend/src/components/MatchReportApiDialog.vue b/frontend/src/components/MatchReportApiDialog.vue index 4eccfe09..e9fd53fe 100755 --- a/frontend/src/components/MatchReportApiDialog.vue +++ b/frontend/src/components/MatchReportApiDialog.vue @@ -3337,33 +3337,65 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr await this.loadTtrPreview(); }, async loadTtrPreview() { - if (!this.canOpenTtrPreview || !this.match?.id || !this.meetingDetails) return; + if (!this.canOpenTtrPreview || !this.meetingDetails) return; this.ttrLoading = true; this.ttrError = ''; try { + if (this.standaloneTest) { + const response = await apiClient.post('/matches/ttr-preview/standalone', { + clubId: this.$store.getters.currentClub, + homePlayers: this.ttrLineupPlayers('home'), + guestPlayers: this.ttrLineupPlayers('guest'), + homeTeamName: this.meetingData?.homeTeamname, + guestTeamName: this.meetingData?.guestTeamname, + }); + const resolvedByKey = new Map((response.data?.players || []).map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player])); + const players = ['home', 'guest'].flatMap((side) => this.ttrLineupPlayers(side).map((player, index) => ({ + side, + name: [player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '), + nuLigaPersonId: player.nuLigaPersonId || null, + ...resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`), + // Test-Aufstellungen sind Platzhalternamen. Nur nicht + // über die API auflösbare Spieler erhalten Testwerte. + ttr: Number.isFinite(Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr)) + ? Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr) + : (side === 'home' ? 1320 - index * 23 : 1345 - index * 19), + adjustmentConstant: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.adjustmentConstant || 16, + source: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.source || 'test', + }))); + this.applyTtrPreviewPlayers(players); + return; + } + if (!this.match?.id) { + this.ttrError = 'Für diesen Spielbericht fehlt die gespeicherte Spiel-ID.'; + return; + } const response = await apiClient.post(`/matches/${this.match.id}/ttr-preview`, { homePlayers: this.ttrLineupPlayers('home'), guestPlayers: this.ttrLineupPlayers('guest'), homeTeamName: this.meetingData?.homeTeamname, guestTeamName: this.meetingData?.guestTeamname, }); - this.ttrPlayers = Array.isArray(response.data?.players) ? response.data.players : []; - const index = new Map(this.ttrPlayers.map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player])); - const snapshots = {}; - this.results.forEach((match, resultIndex) => { - if (this.isTtrDouble(match)) return; - const home = index.get(`home:${this.normalizeTtrName(match.homeName)}`); - const guest = index.get(`guest:${this.normalizeTtrName(match.guestName)}`); - if (!Number.isFinite(Number(home?.ttr)) || !Number.isFinite(Number(guest?.ttr))) return; - snapshots[resultIndex] = { homeTtr: Number(home.ttr), guestTtr: Number(guest.ttr), homeAdjustmentConstant: Number(home.adjustmentConstant || 16), guestAdjustmentConstant: Number(guest.adjustmentConstant || 16) }; - }); - this.ttrSnapshots = snapshots; + this.applyTtrPreviewPlayers(Array.isArray(response.data?.players) ? response.data.players : []); } catch (error) { this.ttrError = error.response?.data?.error || 'TTR-Werte konnten nicht geladen werden.'; } finally { this.ttrLoading = false; } }, + applyTtrPreviewPlayers(players) { + this.ttrPlayers = players; + const index = new Map(this.ttrPlayers.map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player])); + const snapshots = {}; + this.results.forEach((match, resultIndex) => { + if (this.isTtrDouble(match)) return; + const home = index.get(`home:${this.normalizeTtrName(match.homeName)}`); + const guest = index.get(`guest:${this.normalizeTtrName(match.guestName)}`); + if (!Number.isFinite(Number(home?.ttr)) || !Number.isFinite(Number(guest?.ttr))) return; + snapshots[resultIndex] = { homeTtr: Number(home.ttr), guestTtr: Number(guest.ttr), homeAdjustmentConstant: Number(home.adjustmentConstant || 16), guestAdjustmentConstant: Number(guest.adjustmentConstant || 16) }; + }); + this.ttrSnapshots = snapshots; + }, isTtrDouble(match) { const label = String(match?.label || '').toUpperCase(); return label.includes('D') || String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / '); diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 868d4221..d88e032f 100755 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -2999,7 +2999,10 @@ export default { this.fetchingTeamData = true; try { - const response = await apiClient.post('/scheduler/match_results', { + // A manual click must not depend on the scheduler's + // autoUpdateRatings/savePassword flags. This endpoint fetches + // precisely the selected ClubTeam and persists its results. + const response = await apiClient.post('/mytischtennis/fetch-team-data', { clubTeamId: this.selectedTeam.id }); const result = response?.data; @@ -3014,6 +3017,7 @@ export default { } const fetchedCount = + result?.data?.fetchedCount ?? result?.result?.totalFetched ?? result?.result?.fetchedCount ?? result?.result?.data?.fetchedCount ??