336 lines
9.5 KiB
Go
336 lines
9.5 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/uptrace/bun"
|
|
)
|
|
|
|
type Fixture struct {
|
|
bun.BaseModel `bun:"table:fixtures,alias:f"`
|
|
|
|
ID int `bun:"id,pk,autoincrement"`
|
|
SeasonID int `bun:",notnull,unique:round"`
|
|
LeagueID int `bun:",notnull,unique:round"`
|
|
HomeTeamID int `bun:",notnull,unique:round"`
|
|
AwayTeamID int `bun:",notnull,unique:round"`
|
|
Round int `bun:"round,unique:round"`
|
|
GameWeek *int `bun:"game_week"`
|
|
CreatedAt int64 `bun:"created_at,notnull"`
|
|
UpdatedAt *int64 `bun:"updated_at"`
|
|
|
|
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
|
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
|
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
|
AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"`
|
|
}
|
|
|
|
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
|
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
|
) (*Fixture, error) {
|
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
|
}
|
|
homeTeam, err := GetTeam(ctx, tx, homeTeamID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetTeam")
|
|
}
|
|
awayTeam, err := GetTeam(ctx, tx, awayTeamID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetTeam")
|
|
}
|
|
if err = checkTeamsAssociated(season, league, teams, []*Team{homeTeam, awayTeam}); err != nil {
|
|
return nil, errors.Wrap(err, "checkTeamsAssociated")
|
|
}
|
|
fixture := newFixture(season, league, homeTeam, awayTeam, round, time.Now())
|
|
err = Insert(tx, fixture).WithAudit(audit, nil).Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "Insert")
|
|
}
|
|
return fixture, nil
|
|
}
|
|
|
|
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
|
round int, audit *AuditMeta,
|
|
) ([]*Fixture, error) {
|
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
|
}
|
|
fixtures := generateRound(season, league, round, teams)
|
|
err = InsertMultiple(tx, fixtures).WithAudit(audit, nil).Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "InsertMultiple")
|
|
}
|
|
return fixtures, nil
|
|
}
|
|
|
|
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
|
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
|
}
|
|
fixtures, err := GetList[Fixture](tx).
|
|
Where("season_id = ?", sl.SeasonID).
|
|
Where("league_id = ?", sl.LeagueID).
|
|
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
|
Relation("HomeTeam").
|
|
Relation("AwayTeam").
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "GetList")
|
|
}
|
|
return sl, fixtures, nil
|
|
}
|
|
|
|
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
|
|
return GetByID[Fixture](tx, id).
|
|
Relation("Season").
|
|
Relation("League").
|
|
Relation("HomeTeam").
|
|
Relation("AwayTeam").
|
|
Get(ctx)
|
|
}
|
|
|
|
func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) {
|
|
fixtures, err := GetList[Fixture](tx).
|
|
Where("season_id = ?", seasonID).
|
|
Where("league_id = ?", leagueID).
|
|
Where("game_week IS NOT NULL").
|
|
Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID).
|
|
Order("game_week ASC", "round ASC", "id ASC").
|
|
Relation("HomeTeam").
|
|
Relation("AwayTeam").
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetList")
|
|
}
|
|
return fixtures, nil
|
|
}
|
|
|
|
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
|
|
fixtures, err := GetList[Fixture](tx).
|
|
Where("season_id = ?", seasonID).
|
|
Where("league_id = ?", leagueID).
|
|
Where("game_week = ?", gameweek).
|
|
Order("round ASC", "id ASC").
|
|
Relation("HomeTeam").
|
|
Relation("AwayTeam").
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetList")
|
|
}
|
|
return fixtures, nil
|
|
}
|
|
|
|
func GetUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) {
|
|
fixtures, err := GetList[Fixture](tx).
|
|
Where("season_id = ?", seasonID).
|
|
Where("league_id = ?", leagueID).
|
|
Where("game_week IS NULL").
|
|
Order("round ASC", "id ASC").
|
|
Relation("HomeTeam").
|
|
Relation("AwayTeam").
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetList")
|
|
}
|
|
return fixtures, nil
|
|
}
|
|
|
|
func CountUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
|
|
count, err := GetList[Fixture](tx).
|
|
Where("season_id = ?", seasonID).
|
|
Where("league_id = ?", leagueID).
|
|
Where("game_week IS NULL").
|
|
Count(ctx)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "GetList")
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func GetMaxGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
|
|
var maxGameWeek int
|
|
err := tx.NewSelect().
|
|
Model((*Fixture)(nil)).
|
|
Column("game_week").
|
|
Where("season_id = ?", seasonID).
|
|
Where("league_id = ?", leagueID).
|
|
Order("game_week DESC NULLS LAST").
|
|
Limit(1).Scan(ctx, &maxGameWeek)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "tx.NewSelect")
|
|
}
|
|
return maxGameWeek, nil
|
|
}
|
|
|
|
func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, audit *AuditMeta) error {
|
|
details := []any{}
|
|
for _, fixture := range fixtures {
|
|
err := UpdateByID(tx, fixture.ID, fixture).
|
|
Column("game_week").
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
details = append(details, map[string]any{"fixture_id": fixture.ID, "game_week": fixture.GameWeek})
|
|
}
|
|
info := &AuditInfo{
|
|
"fixtures.manage",
|
|
"fixture",
|
|
"multiple",
|
|
map[string]any{"updated": details},
|
|
}
|
|
err := LogSuccess(ctx, tx, audit, info)
|
|
if err != nil {
|
|
return errors.Wrap(err, "LogSuccess")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
|
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetSeasonLeague")
|
|
}
|
|
err = DeleteItem[Fixture](tx).
|
|
Where("season_id = ?", sl.SeasonID).
|
|
Where("league_id = ?", sl.LeagueID).
|
|
WithAudit(audit, nil).
|
|
Delete(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "DeleteItem")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func DeleteFixture(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error {
|
|
err := DeleteByID[Fixture](tx, id).
|
|
WithAudit(audit, nil).
|
|
Delete(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "DeleteByID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newFixture(season *Season, league *League, homeTeam, awayTeam *Team, round int, created time.Time) *Fixture {
|
|
return &Fixture{
|
|
SeasonID: season.ID,
|
|
LeagueID: league.ID,
|
|
HomeTeamID: homeTeam.ID,
|
|
AwayTeamID: awayTeam.ID,
|
|
Round: round,
|
|
CreatedAt: created.Unix(),
|
|
}
|
|
}
|
|
|
|
func checkTeamsAssociated(season *Season, league *League, teamsIn []*Team, toCheck []*Team) error {
|
|
badIDs := []string{}
|
|
master := map[int]bool{}
|
|
for _, team := range teamsIn {
|
|
master[team.ID] = true
|
|
}
|
|
for _, team := range toCheck {
|
|
if !master[team.ID] {
|
|
badIDs = append(badIDs, strconv.Itoa(team.ID))
|
|
}
|
|
}
|
|
ids := strings.Join(badIDs, ",")
|
|
if len(ids) > 0 {
|
|
return BadRequestNotAssociated("season_league", "team",
|
|
"season_id,league_id", "ids",
|
|
fmt.Sprintf("%v,%v", season.ID, league.ID),
|
|
ids)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type versus struct {
|
|
homeTeam *Team
|
|
awayTeam *Team
|
|
}
|
|
|
|
func generateRound(season *Season, league *League, round int, teams []*Team) []*Fixture {
|
|
now := time.Now()
|
|
numTeams := len(teams)
|
|
numGames := numTeams * (numTeams - 1) / 2
|
|
fixtures := make([]*Fixture, numGames)
|
|
for i, matchup := range allTeamsPlay(teams, round) {
|
|
fixtures[i] = newFixture(season, league, matchup.homeTeam, matchup.awayTeam, round, now)
|
|
}
|
|
return fixtures
|
|
}
|
|
|
|
func allTeamsPlay(teams []*Team, round int) []*versus {
|
|
matchups := []*versus{}
|
|
if len(teams) < 2 {
|
|
return matchups
|
|
}
|
|
team1 := teams[0]
|
|
teams = teams[1:]
|
|
matchups = append(matchups, playOtherTeams(team1, teams, round)...)
|
|
matchups = append(matchups, allTeamsPlay(teams, round)...)
|
|
return matchups
|
|
}
|
|
|
|
func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
|
|
matchups := make([]*versus, len(teams))
|
|
for i, opponent := range teams {
|
|
versus := &versus{}
|
|
if (i+round)%2 == 0 {
|
|
versus.homeTeam = team
|
|
versus.awayTeam = opponent
|
|
} else {
|
|
versus.homeTeam = opponent
|
|
versus.awayTeam = team
|
|
}
|
|
matchups[i] = versus
|
|
}
|
|
return matchups
|
|
}
|
|
|
|
func AutoAllocateFixtures(fixtures []*Fixture, gamesPerWeek, startingWeek int) []*Fixture {
|
|
gameWeek := startingWeek
|
|
teamPlays := map[int]int{}
|
|
// Work on a copy so we can track what's remaining
|
|
remaining := make([]*Fixture, len(fixtures))
|
|
copy(remaining, fixtures)
|
|
for len(remaining) > 0 {
|
|
madeProgress := false
|
|
nextRemaining := make([]*Fixture, 0, len(remaining))
|
|
for _, fixture := range remaining {
|
|
if teamPlays[fixture.HomeTeamID] < gamesPerWeek &&
|
|
teamPlays[fixture.AwayTeamID] < gamesPerWeek {
|
|
gw := gameWeek
|
|
fixture.GameWeek = &gw
|
|
teamPlays[fixture.HomeTeamID]++
|
|
teamPlays[fixture.AwayTeamID]++
|
|
madeProgress = true
|
|
} else {
|
|
nextRemaining = append(nextRemaining, fixture)
|
|
}
|
|
}
|
|
if !madeProgress {
|
|
// No fixture could be placed this week — advance to avoid infinite loop
|
|
// (shouldn't happen with valid fixture data, but guards against edge cases)
|
|
gameWeek++
|
|
teamPlays = map[int]int{}
|
|
continue
|
|
}
|
|
remaining = nextRemaining
|
|
if len(remaining) > 0 {
|
|
gameWeek++
|
|
teamPlays = map[int]int{}
|
|
}
|
|
}
|
|
return fixtures
|
|
}
|