v28
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s

This commit is contained in:
2026-04-24 23:46:28 +03:00
parent 3875f2a512
commit 9027cc9900
17 changed files with 4315 additions and 122 deletions
+269
View File
@@ -588,6 +588,10 @@ export class MatchesService {
teamStats: [],
playerParticipations: (() => {
const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = [];
const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished";
if (!canTrustFeedLineups) {
return parsed;
}
if (liveMatch.lineups && typeof liveMatch.lineups === 'object') {
const lu = liveMatch.lineups as Record<string, any>;
const addPlayers = (teamLu: any, teamId: string | null) => {
@@ -630,6 +634,64 @@ export class MatchesService {
if (!match) return null;
const detailDisplayStatus = getDisplayMatchStatus({
state: match.state,
status: match.status,
substate: match.substate,
scoreHome: match.scoreHome,
scoreAway: match.scoreAway,
});
const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus);
if (Array.isArray(match.playerParticipations)) {
if (!canTrustStoredLineups) {
match.playerParticipations = [];
}
const hasHomeLineup = match.playerParticipations.some(
(p: any) => p.teamId === match.homeTeamId && p.isStarting,
);
const hasAwayLineup = match.playerParticipations.some(
(p: any) => p.teamId === match.awayTeamId && p.isStarting,
);
if (!hasHomeLineup || !hasAwayLineup) {
const sidelined =
match.sidelined && typeof match.sidelined === "object"
? (match.sidelined as Record<string, any>)
: {};
const matchDateMs = Number(match.mstUtc || Date.now());
const probableLineups: any[] = [];
if (!hasHomeLineup && match.homeTeamId) {
probableLineups.push(
...(await this.buildProbableLineupForTeam({
teamId: match.homeTeamId,
beforeDateMs: matchDateMs,
sidelinedTeamData: sidelined.homeTeam,
})),
);
}
if (!hasAwayLineup && match.awayTeamId) {
probableLineups.push(
...(await this.buildProbableLineupForTeam({
teamId: match.awayTeamId,
beforeDateMs: matchDateMs,
sidelinedTeamData: sidelined.awayTeam,
})),
);
}
if (probableLineups.length > 0) {
match.playerParticipations = canTrustStoredLineups
? [...match.playerParticipations, ...probableLineups]
: probableLineups;
match.lineupSource = "probable_xi";
}
}
}
// Structure odds
const odds: Record<
string,
@@ -732,4 +794,211 @@ export class MatchesService {
return team?.id || null;
}
private async buildProbableLineupForTeam(params: {
teamId: string;
beforeDateMs: number;
sidelinedTeamData?: any;
matchLimit?: number;
lookbackDays?: number;
maxStalenessDays?: number;
}) {
const matchLimit = params.matchLimit ?? 5;
const lookbackDays = params.lookbackDays ?? 370;
const maxStalenessDays = params.maxStalenessDays ?? 120;
const beforeDateMs = params.beforeDateMs || Date.now();
const minDateMs = Math.max(
0,
beforeDateMs - lookbackDays * 24 * 60 * 60 * 1000,
);
const excluded = this.extractSidelinedPlayerIds(params.sidelinedTeamData);
const rows = await this.prisma.$queryRaw<any[]>`
SELECT
mpp.player_id AS "playerId",
p.name AS "playerName",
mpp.position AS "position",
mpp.shirt_number AS "shirtNumber",
m.id AS "matchId",
m.mst_utc AS "mstUtc"
FROM match_player_participation mpp
JOIN matches m ON m.id = mpp.match_id
JOIN players p ON p.id = mpp.player_id
WHERE mpp.team_id = ${params.teamId}
AND mpp.is_starting = true
AND NOT EXISTS (
SELECT 1
FROM match_player_participation later_mpp
JOIN matches later_m ON later_m.id = later_mpp.match_id
WHERE later_mpp.player_id = mpp.player_id
AND later_mpp.team_id <> ${params.teamId}
AND later_m.mst_utc > m.mst_utc
AND later_m.mst_utc < ${BigInt(beforeDateMs)}
AND (
later_m.status = 'FT'
OR later_m.state = 'postGame'
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
)
)
AND m.id IN (
SELECT m2.id
FROM matches m2
JOIN match_player_participation recent_mpp
ON recent_mpp.match_id = m2.id
AND recent_mpp.team_id = ${params.teamId}
AND recent_mpp.is_starting = true
WHERE (m2.home_team_id = ${params.teamId} OR m2.away_team_id = ${params.teamId})
AND (
m2.status = 'FT'
OR m2.state = 'postGame'
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
)
AND m2.mst_utc < ${BigInt(beforeDateMs)}
AND m2.mst_utc >= ${BigInt(minDateMs)}
GROUP BY m2.id
HAVING COUNT(recent_mpp.*) >= 9
ORDER BY MAX(m2.mst_utc) DESC
LIMIT ${matchLimit}
)
ORDER BY m.mst_utc DESC
`;
if (!rows.length) return [];
const latestMst = Math.max(
...rows.map((row) => Number(row.mstUtc || 0)),
);
const ageDays =
latestMst > 0
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
: Number.POSITIVE_INFINITY;
const staleProjection = ageDays > maxStalenessDays;
const matchOrder = new Map<string, number>();
for (const row of rows) {
const matchId = String(row.matchId);
if (!matchOrder.has(matchId)) {
matchOrder.set(matchId, matchOrder.size);
}
}
const playerMap = new Map<
string,
{
playerId: string;
playerName: string;
position: string | null;
shirtNumber: number | null;
score: number;
starts: number;
lastSeenRank: number;
}
>();
for (const row of rows) {
const playerId = String(row.playerId);
if (excluded.has(playerId)) continue;
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
const recencyWeight = Math.max(1, matchLimit - rank);
const score =
recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
const existing = playerMap.get(playerId);
if (!existing) {
playerMap.set(playerId, {
playerId,
playerName: row.playerName || "Bilinmiyor",
position: row.position ?? null,
shirtNumber:
row.shirtNumber === null || row.shirtNumber === undefined
? null
: Number(row.shirtNumber),
score,
starts: 1,
lastSeenRank: rank,
});
} else {
existing.score += score;
existing.starts += 1;
existing.lastSeenRank = Math.min(existing.lastSeenRank, rank);
existing.position = existing.position || row.position || null;
existing.shirtNumber =
existing.shirtNumber ??
(row.shirtNumber === null || row.shirtNumber === undefined
? null
: Number(row.shirtNumber));
}
}
const ranked = [...playerMap.values()]
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (b.starts !== a.starts) return b.starts - a.starts;
return a.lastSeenRank - b.lastSeenRank;
})
.slice(0, 11);
const coverage = Math.min(1, ranked.length / 11);
const historyScore = Math.min(1, matchOrder.size / matchLimit);
const stableCore = ranked.filter((p) => p.starts >= 2).length / 11;
const stalenessFactor = Math.max(
0.35,
Math.min(1, maxStalenessDays / Math.max(ageDays, 1)),
);
const confidence = Math.max(
0,
Math.min(
staleProjection ? 0.58 : 0.88,
(coverage * 0.45 + historyScore * 0.25 + stableCore * 0.3) *
stalenessFactor,
),
);
return ranked.map((p) => ({
teamId: params.teamId,
isStarting: true,
shirtNumber: p.shirtNumber,
position: p.position,
isProbable: true,
lineupSource: "probable_xi",
projectionConfidence: Number(confidence.toFixed(3)),
projectionAgeDays: Number(ageDays.toFixed(1)),
projectionStale: staleProjection,
projectionMatchLimit: matchLimit,
projectionLookbackDays: lookbackDays,
projectionMaxStalenessDays: maxStalenessDays,
player: {
id: p.playerId,
name: p.playerName,
},
}));
}
private extractSidelinedPlayerIds(teamData: any): Set<string> {
if (!teamData || typeof teamData !== "object") return new Set();
const players = Array.isArray(teamData.players) ? teamData.players : [];
return new Set(
players
.map((player: any) =>
String(
player?.playerId ??
player?.player_id ??
player?.id ??
player?.personId ??
"",
),
)
.filter(Boolean),
);
}
private canTrustStoredLineups(displayStatus?: string): boolean {
const normalized = String(displayStatus || "").toLowerCase();
return (
normalized === "live" ||
normalized === "finished" ||
normalized === "ft"
);
}
}