Commit d6fd393a by Augusto

players transfers/career and rounds

parent 297c6c7e
......@@ -1103,6 +1103,106 @@ const docTemplate = `{
}
}
},
"/import/players/career": {
"post": {
"description": "Fetches /v3/players/{playerWyId}/career for players already present in the DB (players.wy_id not null). Upserts records into player_careers.",
"tags": [
"Import"
],
"summary": "Import player career stats from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/players/transfers": {
"post": {
"description": "Fetches /v3/players/{playerWyId}/transfers for players already present in the DB (players.wy_id not null). Upserts records into player_transfers.",
"tags": [
"Import"
],
"summary": "Import player transfers from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": {
"post": {
"description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.",
......@@ -1169,6 +1269,62 @@ const docTemplate = `{
}
}
},
"/import/rounds": {
"post": {
"description": "Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id values (or a single roundWyId if provided) and upserts into rounds.",
"tags": [
"Import"
],
"summary": "Import rounds from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Wyscout round ID",
"name": "roundWyId",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of rounds processed when roundWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": {
"post": {
"description": "Performs a season import using TheSports season list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
......@@ -1730,6 +1886,106 @@ const docTemplate = `{
"description": "Filter players by birth country name",
"name": "country",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Filter players by role name (supports multiple: role=Defender\u0026role=Midfielder or role=Defender,Midfielder)",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/hudl/{hudlId}/career": {
"get": {
"description": "Returns career statistics by season for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.",
"tags": [
"Players"
],
"summary": "List player career by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/hudl/{hudlId}/transfers": {
"get": {
"description": "Returns transfer history for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.",
"tags": [
"Players"
],
"summary": "List player transfers by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
......@@ -1740,6 +1996,24 @@ const docTemplate = `{
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
......@@ -3211,13 +3485,48 @@ const docTemplate = `{
"$ref": "#/definitions/handlers.matchTeamOut"
},
"round": {
"$ref": "#/definitions/handlers.matchEntityOut"
"$ref": "#/definitions/handlers.matchRoundOut"
},
"season": {
"$ref": "#/definitions/handlers.matchEntityOut"
}
}
},
"handlers.matchRoundOut": {
"type": "object",
"properties": {
"competitionWyId": {
"type": "integer"
},
"endDate": {
"type": "string"
},
"id": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"name": {
"type": "string"
},
"roundNumber": {
"type": "integer"
},
"roundType": {
"type": "string"
},
"seasonWyId": {
"type": "integer"
},
"startDate": {
"type": "string"
},
"wyId": {
"type": "integer"
}
}
},
"handlers.matchTeamOut": {
"type": "object",
"properties": {
......
......@@ -1096,6 +1096,106 @@
}
}
},
"/import/players/career": {
"post": {
"description": "Fetches /v3/players/{playerWyId}/career for players already present in the DB (players.wy_id not null). Upserts records into player_careers.",
"tags": [
"Import"
],
"summary": "Import player career stats from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/players/transfers": {
"post": {
"description": "Fetches /v3/players/{playerWyId}/transfers for players already present in the DB (players.wy_id not null). Upserts records into player_transfers.",
"tags": [
"Import"
],
"summary": "Import player transfers from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": {
"post": {
"description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.",
......@@ -1162,6 +1262,62 @@
}
}
},
"/import/rounds": {
"post": {
"description": "Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id values (or a single roundWyId if provided) and upserts into rounds.",
"tags": [
"Import"
],
"summary": "Import rounds from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Wyscout round ID",
"name": "roundWyId",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of rounds processed when roundWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": {
"post": {
"description": "Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
......@@ -1723,6 +1879,106 @@
"description": "Filter players by birth country name",
"name": "country",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Filter players by role name (supports multiple: role=Defender\u0026role=Midfielder or role=Defender,Midfielder)",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/hudl/{hudlId}/career": {
"get": {
"description": "Returns career statistics by season for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.",
"tags": [
"Players"
],
"summary": "List player career by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/hudl/{hudlId}/transfers": {
"get": {
"description": "Returns transfer history for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.",
"tags": [
"Players"
],
"summary": "List player transfers by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
......@@ -1733,6 +1989,24 @@
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
......@@ -3204,13 +3478,48 @@
"$ref": "#/definitions/handlers.matchTeamOut"
},
"round": {
"$ref": "#/definitions/handlers.matchEntityOut"
"$ref": "#/definitions/handlers.matchRoundOut"
},
"season": {
"$ref": "#/definitions/handlers.matchEntityOut"
}
}
},
"handlers.matchRoundOut": {
"type": "object",
"properties": {
"competitionWyId": {
"type": "integer"
},
"endDate": {
"type": "string"
},
"id": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"name": {
"type": "string"
},
"roundNumber": {
"type": "integer"
},
"roundType": {
"type": "string"
},
"seasonWyId": {
"type": "integer"
},
"startDate": {
"type": "string"
},
"wyId": {
"type": "integer"
}
}
},
"handlers.matchTeamOut": {
"type": "object",
"properties": {
......
......@@ -362,10 +362,33 @@ definitions:
homeTeam:
$ref: '#/definitions/handlers.matchTeamOut'
round:
$ref: '#/definitions/handlers.matchEntityOut'
$ref: '#/definitions/handlers.matchRoundOut'
season:
$ref: '#/definitions/handlers.matchEntityOut'
type: object
handlers.matchRoundOut:
properties:
competitionWyId:
type: integer
endDate:
type: string
id:
type: string
isActive:
type: boolean
name:
type: string
roundNumber:
type: integer
roundType:
type: string
seasonWyId:
type: integer
startDate:
type: string
wyId:
type: integer
type: object
handlers.matchTeamOut:
properties:
id:
......@@ -1139,6 +1162,74 @@ paths:
summary: Import players from TheSports
tags:
- Import
/import/players/career:
post:
description: Fetches /v3/players/{playerWyId}/career for players already present
in the DB (players.wy_id not null). Upserts records into player_careers.
parameters:
- description: Limit number of players processed
in: query
name: limit
type: integer
- description: Process only one Wyscout player wy_id
in: query
name: playerWyId
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import player career stats from Wyscout
tags:
- Import
/import/players/transfers:
post:
description: Fetches /v3/players/{playerWyId}/transfers for players already
present in the DB (players.wy_id not null). Upserts records into player_transfers.
parameters:
- description: Limit number of players processed
in: query
name: limit
type: integer
- description: Process only one Wyscout player wy_id
in: query
name: playerWyId
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import player transfers from Wyscout
tags:
- Import
/import/referees:
post:
description: Imports referees either from Wyscout /v3/referees/{id} (recommended)
......@@ -1185,6 +1276,44 @@ paths:
summary: Import referees
tags:
- Import
/import/rounds:
post:
description: Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id
values (or a single roundWyId if provided) and upserts into rounds.
parameters:
- description: Wyscout round ID
in: query
name: roundWyId
type: integer
- description: Limit number of rounds processed when roundWyId is omitted
in: query
name: limit
type: integer
- description: DB upsert batch size (default 500)
in: query
name: batchSize
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import rounds from Wyscout
tags:
- Import
/import/seasons:
post:
description: Performs a season import using TheSports season list API. If `since`
......@@ -1578,6 +1707,14 @@ paths:
in: query
name: country
type: string
- collectionFormat: csv
description: 'Filter players by role name (supports multiple: role=Defender&role=Midfielder
or role=Defender,Midfielder)'
in: query
items:
type: string
name: role
type: array
responses:
"200":
description: OK
......@@ -1623,6 +1760,82 @@ paths:
summary: Get player by ID
tags:
- Players
/players/hudl/{hudlId}/career:
get:
description: Returns career statistics by season for a player resolved from
the given Hudl ID (players.uid). If no player is found and hudlId is numeric,
it is treated as a Wyscout wy_id.
parameters:
- description: Hudl player identifier (players.uid) or numeric wy_id
in: path
name: hudlId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List player career by Hudl ID
tags:
- Players
/players/hudl/{hudlId}/transfers:
get:
description: Returns transfer history for a player resolved from the given Hudl
ID (players.uid). If no player is found and hudlId is numeric, it is treated
as a Wyscout wy_id.
parameters:
- description: Hudl player identifier (players.uid) or numeric wy_id
in: path
name: hudlId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List player transfers by Hudl ID
tags:
- Players
/players/wyscout/{wyId}:
get:
description: Returns a single player by its provider (wy_id) identifier.
......
......@@ -10,6 +10,7 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
......@@ -59,7 +60,6 @@ require (
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
......
......@@ -40,6 +40,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.MatchLineupPlayer{},
&models.MatchFormation{},
&models.PlayerTransfer{},
&models.PlayerCareer{},
&models.TeamSquad{},
&models.Standing{},
&models.SampleRecord{},
......
......@@ -2,16 +2,19 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models"
......@@ -23,6 +26,967 @@ type ImportHandler struct {
Client *http.Client
}
func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
ctx := c.Request.Context()
teamWyID := 0
if v := strings.TrimSpace(c.Query("teamWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid teamWyId"})
return
}
teamWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
workers := 8
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
batchSize := 2000
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
rps := 12
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportTeamCareer: start teamWyId=%d limit=%d workers=%d batchSize=%d rps=%d", teamWyID, limit, workers, batchSize, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case rateTokens <- struct{}{}:
default:
}
}
}
}()
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
getInt := func(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch vv := v.(type) {
case float64:
return int(vv), true
case int:
return vv, true
default:
return 0, false
}
}
getString := func(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
return s, true
}
importForTeam := func(wyID int) (int, int) {
url := fmt.Sprintf("https://apirest.wyscout.com/v3/teams/%d/career?details=competition,season", wyID)
log.Printf("ImportTeamCareer: fetching teamWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportTeamCareer: failed call (teamWyId=%d): %v", wyID, err)
return 0, 1
}
if status != http.StatusOK {
log.Printf("ImportTeamCareer: non-200 (teamWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 1
}
var payload struct {
Career []map[string]any `json:"career"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportTeamCareer: invalid JSON (teamWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 1
}
if len(payload.Career) == 0 {
return 0, 0
}
now := time.Now().UTC()
rows := make([]models.TeamCareer, 0, len(payload.Career))
for _, row := range payload.Career {
if row == nil {
continue
}
teamID, ok := getInt(row, "teamId")
if !ok || teamID <= 0 {
teamID = wyID
}
seasonID, ok := getInt(row, "seasonId")
if !ok || seasonID <= 0 {
continue
}
competitionID, ok := getInt(row, "competitionId")
if !ok || competitionID <= 0 {
continue
}
roundID, _ := getInt(row, "roundId")
groupID, _ := getInt(row, "groupId")
var roundName *string
if s, ok := getString(row, "roundName"); ok {
roundName = &s
}
var groupName *string
if s, ok := getString(row, "groupName"); ok {
groupName = &s
}
rank, _ := getInt(row, "rank")
matchTotal, _ := getInt(row, "matchTotal")
matchWon, _ := getInt(row, "matchWon")
matchDraw, _ := getInt(row, "matchDraw")
matchLost, _ := getInt(row, "matchLost")
goalPro, _ := getInt(row, "goalPro")
goalAgainst, _ := getInt(row, "goalAgainst")
points, _ := getInt(row, "points")
var seasonJSON json.RawMessage
if v, ok := row["season"]; ok && v != nil {
if b, err := json.Marshal(v); err == nil {
seasonJSON = b
}
}
var competitionJSON json.RawMessage
if v, ok := row["competition"]; ok && v != nil {
if b, err := json.Marshal(v); err == nil {
competitionJSON = b
}
}
tc := models.TeamCareer{
TeamWyID: teamID,
SeasonID: seasonID,
CompetitionID: competitionID,
RoundID: roundID,
RoundName: roundName,
GroupID: groupID,
GroupName: groupName,
Rank: rank,
MatchTotal: matchTotal,
MatchWon: matchWon,
MatchDraw: matchDraw,
MatchLost: matchLost,
GoalPro: goalPro,
GoalAgainst: goalAgainst,
Points: points,
SeasonJSON: seasonJSON,
CompetitionJSON: competitionJSON,
APILastSyncedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
rows = append(rows, tc)
}
if len(rows) == 0 {
return 0, 0
}
if err := h.DB.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "team_wy_id"}, {Name: "season_id"}, {Name: "competition_id"}, {Name: "round_id"}, {Name: "group_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"round_name",
"group_name",
"rank",
"match_total",
"match_won",
"match_draw",
"match_lost",
"goal_pro",
"goal_against",
"points",
"season_json",
"competition_json",
"api_last_synced_at",
"updated_at",
}),
}).
CreateInBatches(&rows, 200).Error; err != nil {
log.Printf("ImportTeamCareer: upsert failed (teamWyId=%d): %v", wyID, err)
return 0, 1
}
return len(rows), 0
}
createdTotal := int64(0)
errorsCount := int64(0)
processed := int64(0)
if teamWyID > 0 {
c1, e1 := importForTeam(teamWyID)
processed = 1
createdTotal = int64(c1)
errorsCount = int64(e1)
log.Printf("ImportTeamCareer: finished processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Team career import completed",
"data": gin.H{
"processed": processed,
"upserted": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
teamIDsCh := make(chan int, workers*4)
resultsCh := make(chan struct {
upserted int
errs int
}, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for id := range teamIDsCh {
c1, e1 := importForTeam(id)
resultsCh <- struct {
upserted int
errs int
}{upserted: c1, errs: e1}
}
}()
}
go func() {
wg.Wait()
close(resultsCh)
}()
produceErr := make(chan error, 1)
go func() {
defer close(teamIDsCh)
errStop := errors.New("stop")
sent := make(map[int]struct{})
count := 0
sendID := func(id int) error {
if id <= 0 {
return nil
}
if _, ok := sent[id]; ok {
return nil
}
count++
if limit > 0 && count > limit {
return errStop
}
select {
case <-ctx.Done():
return ctx.Err()
case teamIDsCh <- id:
sent[id] = struct{}{}
return nil
}
}
// include team IDs from team_children first
var parentIDs []int
if err := h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct().Pluck("parent_team_wy_id", &parentIDs).Error; err != nil {
produceErr <- err
return
}
for _, id := range parentIDs {
if err := sendID(id); err != nil {
if errors.Is(err, errStop) {
produceErr <- nil
return
}
produceErr <- err
return
}
}
var childIDs []int
if err := h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct().Pluck("child_team_wy_id", &childIDs).Error; err != nil {
produceErr <- err
return
}
for _, id := range childIDs {
if err := sendID(id); err != nil {
if errors.Is(err, errStop) {
produceErr <- nil
return
}
produceErr <- err
return
}
}
// include team IDs from teams table (streamed)
var batchTeams []models.Team
q := h.DB.WithContext(ctx).Model(&models.Team{}).Select("wy_id").Where("wy_id IS NOT NULL")
err := q.FindInBatches(&batchTeams, batchSize, func(tx *gorm.DB, batch int) error {
for _, t := range batchTeams {
if t.WyID == nil || *t.WyID <= 0 {
continue
}
if err := sendID(*t.WyID); err != nil {
return err
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errStop) {
produceErr <- err
return
}
produceErr <- nil
}()
for r := range resultsCh {
processed++
createdTotal += int64(r.upserted)
errorsCount += int64(r.errs)
if processed%50 == 0 {
log.Printf("ImportTeamCareer: progress processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
}
}
if err := <-produceErr; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team wyIds"})
return
}
log.Printf("ImportTeamCareer: finished processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
if ctx.Err() != nil {
c.JSON(http.StatusRequestTimeout, gin.H{"error": "request cancelled"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Team career import completed",
"data": gin.H{
"processed": processed,
"upserted": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportPlayerCareer imports player career stats from Wyscout.
// @Summary Import player career stats from Wyscout
// @Description Fetches /v3/players/{playerWyId}/career for players already present in the DB (players.wy_id not null). Upserts records into player_careers.
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/players/career [post]
func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerCareer: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
getInt := func(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch vv := v.(type) {
case float64:
return int(vv), true
case int:
return vv, true
default:
return 0, false
}
}
fetchAndUpsert := func(wyID int) (int, int, int) {
imported, updated, errorsCount := 0, 0, 0
url := fmt.Sprintf("https://apirest.wyscout.com/v3/players/%d/career", wyID)
log.Printf("ImportPlayerCareer: fetching career playerWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportPlayerCareer: failed Wyscout career call (playerWyId=%d): %v", wyID, err)
return 0, 0, 1
}
if status != http.StatusOK {
log.Printf("ImportPlayerCareer: non-200 (playerWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 0, 1
}
var payload struct {
Career []map[string]any `json:"career"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportPlayerCareer: invalid JSON (playerWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 0, 1
}
if len(payload.Career) == 0 {
log.Printf("ImportPlayerCareer: playerWyId=%d career=0", wyID)
return 0, 0, 0
}
log.Printf("ImportPlayerCareer: playerWyId=%d career=%d", wyID, len(payload.Career))
now := time.Now().UTC()
for _, row := range payload.Career {
if row == nil {
errorsCount++
continue
}
pid, ok := getInt(row, "playerId")
if !ok || pid <= 0 {
pid = wyID
}
teamID, ok := getInt(row, "teamId")
if !ok || teamID <= 0 {
errorsCount++
continue
}
seasonID, ok := getInt(row, "seasonId")
if !ok || seasonID <= 0 {
errorsCount++
continue
}
competitionID, ok := getInt(row, "competitionId")
if !ok || competitionID <= 0 {
errorsCount++
continue
}
var rec models.PlayerCareer
if err := h.DB.
Where("player_wy_id = ? AND team_id = ? AND season_id = ? AND competition_id = ?", pid, teamID, seasonID, competitionID).
First(&rec).Error; err != nil {
if err != gorm.ErrRecordNotFound {
errorsCount++
continue
}
rec = models.PlayerCareer{}
}
rec.PlayerWyID = pid
rec.TeamID = teamID
rec.SeasonID = seasonID
rec.CompetitionID = competitionID
if v, ok := getInt(row, "shirtNumber"); ok {
rec.ShirtNumber = v
}
if v, ok := getInt(row, "goal"); ok {
rec.Goal = v
}
if v, ok := getInt(row, "penalties"); ok {
rec.Penalties = v
}
if v, ok := getInt(row, "appearances"); ok {
rec.Appearances = v
}
if v, ok := getInt(row, "yellowCard"); ok {
rec.YellowCard = v
}
if v, ok := getInt(row, "redCards"); ok {
rec.RedCards = v
}
if v, ok := getInt(row, "substituteIn"); ok {
rec.SubstituteIn = v
}
if v, ok := getInt(row, "substituteOut"); ok {
rec.SubstituteOut = v
}
if v, ok := getInt(row, "substituteOnBench"); ok {
rec.SubOnBench = v
}
if v, ok := getInt(row, "minutesPlayed"); ok {
rec.MinutesPlayed = v
}
rec.APILastSyncedAt = &now
if rec.ID == "" {
if err := h.DB.Create(&rec).Error; err != nil {
errorsCount++
continue
}
imported++
} else {
if err := h.DB.Save(&rec).Error; err != nil {
errorsCount++
continue
}
updated++
}
}
log.Printf("ImportPlayerCareer: playerWyId=%d done imported=%d updated=%d errors=%d", wyID, imported, updated, errorsCount)
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
log.Printf("ImportPlayerCareer: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player career import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": 1,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
var players []models.Player
if err := h.DB.Select("wy_id").Where("wy_id IS NOT NULL").Find(&players).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerCareer: players in DB with wy_id=%d", len(players))
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= limit {
break
}
log.Printf("ImportPlayerCareer: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
processed++
}
log.Printf("ImportPlayerCareer: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player career import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportPlayerTransfers imports player transfers from Wyscout.
// @Summary Import player transfers from Wyscout
// @Description Fetches /v3/players/{playerWyId}/transfers for players already present in the DB (players.wy_id not null). Upserts records into player_transfers.
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/players/transfers [post]
func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerTransfers: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
parseDateAny := func(v any) *time.Time {
switch vv := v.(type) {
case string:
vv = strings.TrimSpace(vv)
if vv == "" || vv == "0000-00-00" {
return nil
}
if t, err := time.Parse(time.RFC3339, vv); err == nil {
ut := t.UTC()
return &ut
}
if t, err := time.Parse("2006-01-02", vv); err == nil {
ut := t.UTC()
return &ut
}
return nil
case float64:
if vv <= 0 {
return nil
}
ut := time.Unix(int64(vv), 0).UTC()
return &ut
default:
return nil
}
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
fetchAndUpsert := func(wyID int) (int, int, int) {
imported, updated, errorsCount := 0, 0, 0
url := fmt.Sprintf("https://apirest.wyscout.com/v3/players/%d/transfers", wyID)
log.Printf("ImportPlayerTransfers: fetching transfers playerWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportPlayerTransfers: failed Wyscout transfers call (playerWyId=%d): %v", wyID, err)
return 0, 0, 1
}
if status != http.StatusOK {
log.Printf("ImportPlayerTransfers: non-200 (playerWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 0, 1
}
var payload struct {
Transfers []map[string]any `json:"transfer"`
WyID int `json:"wyId"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportPlayerTransfers: invalid JSON (playerWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 0, 1
}
if len(payload.Transfers) == 0 {
log.Printf("ImportPlayerTransfers: playerWyId=%d transfers=0", wyID)
return 0, 0, 0
}
log.Printf("ImportPlayerTransfers: playerWyId=%d transfers=%d", wyID, len(payload.Transfers))
now := time.Now().UTC()
for _, trow := range payload.Transfers {
if trow == nil {
errorsCount++
continue
}
transferID := 0
if v, ok := trow["transferId"].(float64); ok {
transferID = int(v)
}
if transferID <= 0 {
errorsCount++
continue
}
var rec models.PlayerTransfer
if err := h.DB.Where("transfer_id = ?", transferID).First(&rec).Error; err != nil {
if err != gorm.ErrRecordNotFound {
errorsCount++
continue
}
rec = models.PlayerTransfer{}
}
rec.TransferID = transferID
rec.PlayerWyID = wyID
if v, ok := trow["playerId"].(float64); ok {
// prefer API value if present
pid := int(v)
if pid > 0 {
rec.PlayerWyID = pid
}
}
if v, ok := trow["fromTeamId"].(float64); ok {
id := int(v)
rec.FromTeamID = &id
}
if v, ok := trow["toTeamId"].(float64); ok {
id := int(v)
rec.ToTeamID = &id
}
if v, ok := trow["fromTeamName"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.FromTeamName = &s
}
if v, ok := trow["toTeamName"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.ToTeamName = &s
}
if v, ok := trow["active"].(float64); ok {
rec.IsActive = int(v) == 1
} else if v, ok := trow["active"].(bool); ok {
rec.IsActive = v
}
rec.StartDate = parseDateAny(trow["startDate"])
rec.EndDate = parseDateAny(trow["endDate"])
if v, ok := trow["type"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.Type = &s
}
if v, ok := trow["value"].(float64); ok {
iv := int(v)
rec.Value = &iv
}
if v, ok := trow["currency"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.Currency = &s
}
rec.AnnounceDate = parseDateAny(trow["announceDate"])
rec.APILastSyncedAt = &now
if rec.ID == "" {
if err := h.DB.Create(&rec).Error; err != nil {
errorsCount++
continue
}
imported++
} else {
if err := h.DB.Save(&rec).Error; err != nil {
errorsCount++
continue
}
updated++
}
}
log.Printf("ImportPlayerTransfers: playerWyId=%d done imported=%d updated=%d errors=%d", wyID, imported, updated, errorsCount)
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
log.Printf("ImportPlayerTransfers: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player transfers import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": 1,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
var players []models.Player
if err := h.DB.Select("wy_id").Where("wy_id IS NOT NULL").Find(&players).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerTransfers: players in DB with wy_id=%d", len(players))
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= limit {
break
}
log.Printf("ImportPlayerTransfers: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
processed++
}
log.Printf("ImportPlayerTransfers: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player transfers import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportSeasons imports seasons from TheSports API.
// @Summary Import seasons from TheSports
// @Description Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
......@@ -950,12 +1914,16 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
}
imp := rg.Group("/import")
imp.POST("/rounds", h.ImportRounds)
imp.POST("/areas", h.ImportAreas)
imp.POST("/seasons", h.ImportSeasons)
imp.POST("/standings", h.ImportStandings)
imp.POST("/competitions", h.ImportCompetitions)
imp.POST("/players", h.ImportPlayers)
imp.POST("/players/transfers", h.ImportPlayerTransfers)
imp.POST("/players/career", h.ImportPlayerCareer)
imp.POST("/teams", h.ImportTeams)
imp.POST("/teams/career", h.ImportTeamCareer)
imp.POST("/teams/images", h.ImportTeamImages)
imp.POST("/coaches", h.ImportCoaches)
imp.POST("/referees", h.ImportReferees)
......@@ -968,6 +1936,236 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
imp.POST("/matches/formations", h.ImportMatchFormations)
}
// ImportRounds imports round metadata from Wyscout.
//
// It fetches /v3/rounds/{roundWyId} for all distinct round_wy_id values found in matches,
// then upserts into rounds.
// @Summary Import rounds from Wyscout
// @Description Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id values (or a single roundWyId if provided) and upserts into rounds.
// @Tags Import
// @Param roundWyId query int false "Wyscout round ID"
// @Param limit query int false "Limit number of rounds processed when roundWyId is omitted"
// @Param batchSize query int false "DB upsert batch size (default 500)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/rounds [post]
func (h *ImportHandler) ImportRounds(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
roundWyID := 0
if v := strings.TrimSpace(c.Query("roundWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roundWyId"})
return
}
roundWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
batchSize := 500
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
type roundPayload struct {
WyID int `json:"wyId"`
Name string `json:"name"`
Type string `json:"type"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
CompetitionID int `json:"competitionId"`
SeasonID int `json:"seasonId"`
}
parseDate := func(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil
}
tu := t.UTC()
return &tu
}
toRound := func(p roundPayload) *models.Round {
if p.WyID <= 0 {
return nil
}
rec := &models.Round{}
rec.WyID = p.WyID
rec.Name = p.Name
rec.IsActive = true
if strings.TrimSpace(p.Type) != "" {
rec.RoundType = strings.TrimSpace(p.Type)
}
if p.CompetitionID > 0 {
cwy := p.CompetitionID
rec.CompetitionWyID = &cwy
}
if p.SeasonID > 0 {
swy := p.SeasonID
rec.SeasonWyID = &swy
}
rec.StartDate = parseDate(p.StartDate)
rec.EndDate = parseDate(p.EndDate)
return rec
}
upsertBatch := func(batch []models.Round) error {
if len(batch) == 0 {
return nil
}
return h.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "wy_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"round_type",
"competition_wy_id",
"season_wy_id",
"start_date",
"end_date",
"updated_at",
}),
}).CreateInBatches(&batch, batchSize).Error
}
roundWyIDs := make([]int, 0, 1024)
if roundWyID > 0 {
roundWyIDs = append(roundWyIDs, roundWyID)
} else {
var rows []struct {
RoundWyID int `gorm:"column:round_wy_id"`
}
q := h.DB.Model(&models.Match{}).Select("DISTINCT round_wy_id").Where("round_wy_id IS NOT NULL")
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&rows).Error; err != nil {
log.Printf("ImportRounds: failed to list match round ids: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match round ids"})
return
}
for _, r := range rows {
if r.RoundWyID > 0 {
roundWyIDs = append(roundWyIDs, r.RoundWyID)
}
}
}
log.Printf("ImportRounds: rounds to process=%d batchSize=%d", len(roundWyIDs), batchSize)
upserted := 0
errorsCount := 0
processed := 0
batch := make([]models.Round, 0, batchSize)
flush := func() {
if len(batch) == 0 {
return
}
if err := upsertBatch(batch); err != nil {
log.Printf("ImportRounds: batch upsert failed err=%v", err)
errorsCount += len(batch)
} else {
upserted += len(batch)
}
batch = batch[:0]
}
for _, id := range roundWyIDs {
url := fmt.Sprintf("https://apirest.wyscout.com/v3/rounds/%d", id)
log.Printf("ImportRounds: fetching roundWyId=%d", id)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportRounds: request failed roundWyId=%d err=%v", id, err)
errorsCount++
continue
}
if status != http.StatusOK {
log.Printf("ImportRounds: non-200 roundWyId=%d status=%d body=%s", id, status, truncate(body))
errorsCount++
continue
}
var payload roundPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportRounds: invalid JSON roundWyId=%d err=%v body=%s", id, err, truncate(body))
errorsCount++
continue
}
rec := toRound(payload)
if rec == nil {
errorsCount++
continue
}
batch = append(batch, *rec)
if len(batch) >= batchSize {
flush()
}
processed++
if limit > 0 && roundWyID == 0 && processed >= limit {
break
}
}
flush()
log.Printf("ImportRounds: done upserted=%d errors=%d processed=%d", upserted, errorsCount, processed)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Rounds import completed",
"data": gin.H{
"upserted": upserted,
"errors": errorsCount,
"roundsProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportMatchFormations imports match formations from Wyscout v4.
// @Summary Import match formations from Wyscout v4
// @Description Fetches /v4/matches/{matchWyId}/formations for a given matchWyId. If matchWyId is omitted, processes matches in the DB with a wy_id. Stores formations in match_formations.
......@@ -986,6 +2184,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return
}
ctx := c.Request.Context()
matchWyID := 0
if v := strings.TrimSpace(c.Query("matchWyId")); v != "" {
n, err := strconv.Atoi(v)
......@@ -1003,7 +2203,49 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
}
}
log.Printf("ImportMatchFormations: start matchWyId=%d limit=%d", matchWyID, limit)
workers := 8
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
batchSize := 2000
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
rps := 12
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportMatchFormations: start matchWyId=%d limit=%d workers=%d batchSize=%d rps=%d", matchWyID, limit, workers, batchSize, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case rateTokens <- struct{}{}:
default:
}
}
}
}()
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
......@@ -1068,7 +2310,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return 0, 1
}
tx := h.DB.Begin()
tx := h.DB.WithContext(ctx).Begin()
if err := tx.Error; err != nil {
log.Printf("ImportMatchFormations: matchWyId=%d tx begin failed: %v", matchWyID, err)
return 0, 1
......@@ -1088,7 +2330,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
now := time.Now().UTC()
created := 0
persistSide := func(side string, segs []formationSegment) {
persistSide := func(side string, segs []formationSegment) bool {
rows := make([]models.MatchFormation, 0, len(segs))
for _, seg := range segs {
if seg.ID <= 0 {
continue
......@@ -1106,7 +2349,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
if err != nil {
tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d failed to encode players wyId=%d: %v", matchWyID, seg.ID, err)
return
return false
}
mf := models.MatchFormation{
......@@ -1116,6 +2359,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
APILastSyncedAt: &now,
APISyncStatus: "synced",
PlayersJSON: playersRaw,
CreatedAt: now,
UpdatedAt: now,
}
if scheme != "" {
mf.Scheme = &scheme
......@@ -1139,21 +2384,25 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
mf.PlayersOnField = &pof
}
if err := tx.Create(&mf).Error; err != nil {
tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d insert failed wyId=%d side=%s: %v", matchWyID, seg.ID, side, err)
return
rows = append(rows, mf)
}
created++
if len(rows) == 0 {
return true
}
if err := tx.CreateInBatches(&rows, 200).Error; err != nil {
tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d batch insert failed side=%s: %v", matchWyID, side, err)
return false
}
created += len(rows)
return true
}
persistSide("home", payload.Home)
if tx.Error != nil {
if ok := persistSide("home", payload.Home); !ok {
return created, 1
}
persistSide("away", payload.Away)
if tx.Error != nil {
if ok := persistSide("away", payload.Away); !ok {
return created, 1
}
......@@ -1166,36 +2415,100 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return created, 0
}
matchIDs := make([]int, 0)
createdTotal := int64(0)
errorsCount := int64(0)
processed := int64(0)
if matchWyID > 0 {
matchIDs = append(matchIDs, matchWyID)
} else {
q := h.DB.Model(&models.Match{}).Select("wy_id").Where("wy_id IS NOT NULL")
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Pluck("wy_id", &matchIDs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match wyIds"})
c1, e1 := importForMatch(matchWyID)
processed = 1
createdTotal = int64(c1)
errorsCount = int64(e1)
log.Printf("ImportMatchFormations: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Match formations import completed",
"data": gin.H{
"processed": processed,
"created": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
matchIDsCh := make(chan int, workers*4)
resultsCh := make(chan struct {
created int
errs int
}, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for id := range matchIDsCh {
c1, e1 := importForMatch(id)
resultsCh <- struct {
created int
errs int
}{created: c1, errs: e1}
}
}()
}
createdTotal := 0
errorsCount := 0
processed := 0
for _, id := range matchIDs {
if id <= 0 {
go func() {
wg.Wait()
close(resultsCh)
}()
produceErr := make(chan error, 1)
go func() {
defer close(matchIDsCh)
errStop := errors.New("stop")
count := 0
var batchMatches []models.Match
q := h.DB.WithContext(ctx).Model(&models.Match{}).Select("wy_id").Where("wy_id IS NOT NULL")
err := q.FindInBatches(&batchMatches, batchSize, func(tx *gorm.DB, batch int) error {
for _, m := range batchMatches {
if m.WyID == nil || *m.WyID <= 0 {
continue
}
count++
if limit > 0 && count > limit {
return errStop
}
select {
case <-ctx.Done():
return ctx.Err()
case matchIDsCh <- *m.WyID:
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errStop) {
produceErr <- err
return
}
produceErr <- nil
}()
for r := range resultsCh {
processed++
log.Printf("ImportMatchFormations: processing %d/%d matchWyId=%d", processed, len(matchIDs), id)
created, errs := importForMatch(id)
createdTotal += created
errorsCount += errs
createdTotal += int64(r.created)
errorsCount += int64(r.errs)
if processed%50 == 0 {
log.Printf("ImportMatchFormations: progress processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
}
}
if err := <-produceErr; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match wyIds"})
return
}
log.Printf("ImportMatchFormations: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{
......
......@@ -54,12 +54,25 @@ type matchEntityOut struct {
Name *string `json:"name,omitempty"`
}
type matchRoundOut struct {
ID *string `json:"id,omitempty"`
WyID *int `json:"wyId,omitempty"`
Name *string `json:"name,omitempty"`
RoundType *string `json:"roundType,omitempty"`
RoundNumber *int `json:"roundNumber,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
EndDate *time.Time `json:"endDate,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
CompetitionWyID *int `json:"competitionWyId,omitempty"`
SeasonWyID *int `json:"seasonWyId,omitempty"`
}
type matchOut struct {
Match models.Match `json:"-"`
HomeTeam *matchTeamOut `json:"homeTeam,omitempty"`
AwayTeam *matchTeamOut `json:"awayTeam,omitempty"`
Season *matchEntityOut `json:"season,omitempty"`
Round *matchEntityOut `json:"round,omitempty"`
Round *matchRoundOut `json:"round,omitempty"`
Competition *matchEntityOut `json:"competition,omitempty"`
}
......@@ -98,6 +111,7 @@ func (m matchOut) MarshalJSON() ([]byte, error) {
delete(base, "refereeTsId")
delete(base, "relatedTsId")
delete(base, "roundGroupNum")
delete(base, "roundNum")
delete(base, "roundStageTsId")
delete(base, "seasonTsId")
delete(base, "statusId")
......@@ -215,21 +229,49 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match
}
}
roundByWyID := map[int]string{}
roundByWyID := map[int]matchRoundOut{}
if len(roundWyIDs) > 0 {
var rounds []struct {
ID string `gorm:"column:id"`
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
RoundType string `gorm:"column:round_type"`
RoundNumber *int `gorm:"column:round_number"`
StartDate *time.Time `gorm:"column:start_date"`
EndDate *time.Time `gorm:"column:end_date"`
IsActive bool `gorm:"column:is_active"`
CompetitionWyID *int `gorm:"column:competition_wy_id"`
SeasonWyID *int `gorm:"column:season_wy_id"`
}
if err := h.DB.WithContext(ctx).
Model(&models.Round{}).
Select("wy_id", "name").
Select("id", "wy_id", "name", "round_type", "round_number", "start_date", "end_date", "is_active", "competition_wy_id", "season_wy_id").
Where("wy_id IN ?", roundWyIDs).
Find(&rounds).Error; err != nil {
return nil, err
}
for _, r := range rounds {
roundByWyID[r.WyID] = r.Name
id := r.ID
wyID := r.WyID
name := r.Name
var typ *string
if r.RoundType != "" {
t := r.RoundType
typ = &t
}
active := r.IsActive
roundByWyID[r.WyID] = matchRoundOut{
ID: &id,
WyID: &wyID,
Name: &name,
RoundType: typ,
RoundNumber: r.RoundNumber,
StartDate: r.StartDate,
EndDate: r.EndDate,
IsActive: &active,
CompetitionWyID: r.CompetitionWyID,
SeasonWyID: r.SeasonWyID,
}
}
}
......@@ -350,13 +392,14 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match
season.Name = &n
}
}
var round *matchEntityOut
var round *matchRoundOut
if m.RoundWyID != nil {
if r, ok := roundByWyID[*m.RoundWyID]; ok {
rr := r
round = &rr
} else {
wyID := *m.RoundWyID
round = &matchEntityOut{WyID: &wyID}
if name, ok := roundByWyID[*m.RoundWyID]; ok && name != "" {
n := name
round.Name = &n
round = &matchRoundOut{WyID: &wyID}
}
}
out = append(out, matchOut{
......
......@@ -15,6 +15,7 @@ import (
)
type PlayerHandler struct {
DB *gorm.DB
Service services.PlayerService
Teams services.TeamService
Areas services.AreaService
......@@ -24,12 +25,14 @@ func RegisterPlayerRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewPlayerService(db)
teamService := services.NewTeamService(db)
areaService := services.NewAreaService(db)
h := &PlayerHandler{Service: service, Teams: teamService, Areas: areaService}
h := &PlayerHandler{DB: db, Service: service, Teams: teamService, Areas: areaService}
players := rg.Group("/players")
players.GET("", h.List)
players.GET("/wyscout/:wyId", h.GetByProviderID)
players.GET("/provider/:providerId", h.GetByAnyProviderID)
players.GET("/hudl/:hudlId/transfers", h.ListTransfersByHudlID)
players.GET("/hudl/:hudlId/career", h.ListCareerByHudlID)
players.GET("/:id", h.GetByID)
}
......@@ -69,6 +72,7 @@ type StructuredPlayer struct {
CurrentNationalTeamID *int `json:"-"`
CountryTsID *string `json:"-"`
CountryName *string `json:"-"`
Country *AreaSummary `json:"country,omitempty"`
Gender *string `json:"gender"`
Status string `json:"status"`
JerseyNumber *int `json:"-"`
......@@ -261,6 +265,28 @@ func addPlayerCountries(structured []StructuredPlayer, players []models.Player,
if a, ok := areasByTsID[tsID]; ok {
name := a.Name
structured[i].CountryName = &name
s := toAreaSummary(a)
structured[i].Country = &s
}
}
}
func addPlayerBirthPassportAreas(structured []StructuredPlayer, players []models.Player, areasByWyID map[int]models.Area) {
if len(structured) != len(players) {
return
}
for i := range structured {
if players[i].BirthAreaWyID != nil {
if a, ok := areasByWyID[*players[i].BirthAreaWyID]; ok {
s := toAreaSummary(a)
structured[i].BirthArea = &s
}
}
if players[i].PassportAreaWyID != nil {
if a, ok := areasByWyID[*players[i].PassportAreaWyID]; ok {
s := toAreaSummary(a)
structured[i].PassportArea = &s
}
}
}
}
......@@ -334,6 +360,7 @@ func valueOrDefault(s *string, def string) string {
// @Param name query string false "Filter players by name (short, first, middle, or last)"
// @Param teamId query string false "Filter players by current team ID"
// @Param country query string false "Filter players by birth country name"
// @Param role query []string false "Filter players by role name (supports multiple: role=Defender&role=Midfielder or role=Defender,Midfielder)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /players [get]
......@@ -353,6 +380,23 @@ func (h *PlayerHandler) List(c *gin.Context) {
name := c.Query("name")
teamID := c.Query("teamId")
country := c.Query("country")
rolesQuery := c.QueryArray("role")
if len(rolesQuery) == 0 {
rolesQuery = c.QueryArray("role[]")
}
if len(rolesQuery) == 0 {
rolesQuery = c.QueryArray("roles")
}
roles := make([]string, 0, len(rolesQuery))
for _, raw := range rolesQuery {
for _, r := range strings.Split(raw, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
roles = append(roles, r)
}
}
endpoint := "/players"
if name != "" {
......@@ -369,6 +413,7 @@ func (h *PlayerHandler) List(c *gin.Context) {
Name: name,
TeamID: teamID,
Country: country,
Roles: roles,
})
if err != nil {
respondError(c, err)
......@@ -706,6 +751,209 @@ func (h *PlayerHandler) GetByID(c *gin.Context) {
})
}
// ListTransfersByHudlID returns player transfers for a player identified by Hudl ID.
// @Summary List player transfers by Hudl ID
// @Description Returns transfer history for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.
// @Tags Players
// @Param hudlId path string true "Hudl player identifier (players.uid) or numeric wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /players/hudl/{hudlId}/transfers [get]
func (h *PlayerHandler) ListTransfersByHudlID(c *gin.Context) {
hudlID := c.Param("hudlId")
transfers, err := h.Service.ListTransfersByHudlID(c.Request.Context(), hudlID)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/hudl/%s/transfers", hudlID)
c.JSON(http.StatusOK, gin.H{
"data": transfers,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// ListCareerByHudlID returns player career stats for a player identified by Hudl ID.
// @Summary List player career by Hudl ID
// @Description Returns career statistics by season for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.
// @Tags Players
// @Param hudlId path string true "Hudl player identifier (players.uid) or numeric wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /players/hudl/{hudlId}/career [get]
func (h *PlayerHandler) ListCareerByHudlID(c *gin.Context) {
hudlID := c.Param("hudlId")
career, err := h.Service.ListCareerByHudlID(c.Request.Context(), hudlID)
if err != nil {
respondError(c, err)
return
}
type teamSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
Image *string `json:"image,omitempty"`
}
type competitionSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
Logo *string `json:"logo,omitempty"`
}
type seasonSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
}
type careerOut struct {
models.PlayerCareer
Team *teamSummary `json:"team,omitempty"`
Season *seasonSummary `json:"season,omitempty"`
Competition *competitionSummary `json:"competition,omitempty"`
}
teamIDs := make([]int, 0, len(career))
seasonIDs := make([]int, 0, len(career))
competitionIDs := make([]int, 0, len(career))
seenTeam := map[int]struct{}{}
seenSeason := map[int]struct{}{}
seenCompetition := map[int]struct{}{}
for _, row := range career {
if row.TeamID > 0 {
if _, ok := seenTeam[row.TeamID]; !ok {
seenTeam[row.TeamID] = struct{}{}
teamIDs = append(teamIDs, row.TeamID)
}
}
if row.SeasonID > 0 {
if _, ok := seenSeason[row.SeasonID]; !ok {
seenSeason[row.SeasonID] = struct{}{}
seasonIDs = append(seasonIDs, row.SeasonID)
}
}
if row.CompetitionID > 0 {
if _, ok := seenCompetition[row.CompetitionID]; !ok {
seenCompetition[row.CompetitionID] = struct{}{}
competitionIDs = append(competitionIDs, row.CompetitionID)
}
}
}
teamsByWyID := map[int]teamSummary{}
if len(teamIDs) > 0 {
var teams []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
ImageDataURL *string `gorm:"column:image_data_url"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Team{}).
Select("wy_id", "name", "image_data_url").
Where("wy_id IN ?", teamIDs).
Find(&teams).Error; err == nil {
for _, t := range teams {
name := t.Name
var img *string
if t.ImageDataURL != nil && *t.ImageDataURL != "" {
v := *t.ImageDataURL
img = &v
}
teamsByWyID[t.WyID] = teamSummary{WyID: t.WyID, Name: &name, Image: img}
}
}
}
seasonsByWyID := map[int]seasonSummary{}
if len(seasonIDs) > 0 {
var seasons []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Season{}).
Select("wy_id", "name").
Where("wy_id IN ?", seasonIDs).
Find(&seasons).Error; err == nil {
for _, s := range seasons {
name := s.Name
seasonsByWyID[s.WyID] = seasonSummary{WyID: s.WyID, Name: &name}
}
}
}
competitionsByWyID := map[int]competitionSummary{}
if len(competitionIDs) > 0 {
var comps []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
Logo *string `gorm:"column:logo"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Competition{}).
Select("wy_id", "name", "logo").
Where("wy_id IN ?", competitionIDs).
Find(&comps).Error; err == nil {
for _, comp := range comps {
name := comp.Name
var logo *string
if comp.Logo != nil && *comp.Logo != "" {
v := *comp.Logo
logo = &v
}
competitionsByWyID[comp.WyID] = competitionSummary{WyID: comp.WyID, Name: &name, Logo: logo}
}
}
}
out := make([]careerOut, 0, len(career))
for _, row := range career {
var team *teamSummary
if v, ok := teamsByWyID[row.TeamID]; ok {
tmp := v
team = &tmp
}
var season *seasonSummary
if v, ok := seasonsByWyID[row.SeasonID]; ok {
tmp := v
season = &tmp
}
var competition *competitionSummary
if v, ok := competitionsByWyID[row.CompetitionID]; ok {
tmp := v
competition = &tmp
}
out = append(out, careerOut{PlayerCareer: row, Team: team, Season: season, Competition: competition})
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/hudl/%s/career", hudlID)
c.JSON(http.StatusOK, gin.H{
"data": out,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByAnyProviderID returns a single player by wy_id (numeric) or ts_id (string)
func (h *PlayerHandler) GetByAnyProviderID(c *gin.Context) {
providerID := c.Param("providerId")
......
......@@ -160,6 +160,43 @@ func (h *TeamHandler) ListPlayersByWyID(c *gin.Context) {
structured = append(structured, toStructuredPlayer(p))
}
playerAreaWyIDs := make([]int, 0, len(players)*2)
playerAreaSeen := make(map[int]struct{}, len(players)*2)
for _, p := range players {
if p.BirthAreaWyID != nil && *p.BirthAreaWyID > 0 {
if _, ok := playerAreaSeen[*p.BirthAreaWyID]; !ok {
playerAreaSeen[*p.BirthAreaWyID] = struct{}{}
playerAreaWyIDs = append(playerAreaWyIDs, *p.BirthAreaWyID)
}
}
if p.PassportAreaWyID != nil && *p.PassportAreaWyID > 0 {
if _, ok := playerAreaSeen[*p.PassportAreaWyID]; !ok {
playerAreaSeen[*p.PassportAreaWyID] = struct{}{}
playerAreaWyIDs = append(playerAreaWyIDs, *p.PassportAreaWyID)
}
}
}
if len(playerAreaWyIDs) > 0 {
areas, err := h.Areas.GetByWyIDs(c.Request.Context(), playerAreaWyIDs)
if err != nil {
respondError(c, err)
return
}
areasByWyIDForPlayers := make(map[int]models.Area, len(areas))
for _, a := range areas {
wyStr := strings.TrimSpace(a.WyID)
if wyStr == "" {
continue
}
wy, err := strconv.Atoi(wyStr)
if err != nil {
continue
}
areasByWyIDForPlayers[wy] = a
}
addPlayerBirthPassportAreas(structured, players, areasByWyIDForPlayers)
}
wyIDs := make([]int, 0, len(players))
wySeen := make(map[int]struct{}, len(players))
for _, p := range players {
......
......@@ -495,35 +495,111 @@ func (mf *MatchFormation) BeforeCreate(tx *gorm.DB) (err error) {
type PlayerTransfer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID int `gorm:"column:ts_id;uniqueIndex" json:"tsId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"`
PlayerTsID *int `gorm:"column:player_ts_id" json:"playerTsId"`
FromTeamWyID *int `gorm:"column:from_team_wy_id" json:"fromTeamWyId"`
FromTeamTsID *int `gorm:"column:from_team_ts_id" json:"fromTeamTsId"`
ToTeamWyID *int `gorm:"column:to_team_wy_id" json:"toTeamWyId"`
ToTeamTsID *int `gorm:"column:to_team_ts_id" json:"toTeamTsId"`
TransferID int `gorm:"column:transfer_id;uniqueIndex" json:"transferId"`
PlayerWyID int `gorm:"column:player_wy_id;index" json:"playerId"`
FromTeamID *int `gorm:"column:from_team_id" json:"fromTeamId"`
FromTeamName *string `gorm:"column:from_team_name" json:"fromTeamName"`
ToTeamID *int `gorm:"column:to_team_id" json:"toTeamId"`
ToTeamName *string `gorm:"column:to_team_name" json:"toTeamName"`
TransferDate *time.Time `gorm:"column:transfer_date" json:"transferDate"`
IsActive bool `gorm:"column:is_active;default:false" json:"active"`
StartDate *time.Time `gorm:"column:start_date" json:"startDate"`
EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
TransferType *string `gorm:"column:transfer_type" json:"transferType"`
TransferFee *float64 `gorm:"column:transfer_fee" json:"transferFee"`
Currency string `gorm:"column:currency;default:EUR" json:"currency"`
ContractLength *int `gorm:"column:contract_length" json:"contractLength"`
Season *string `gorm:"column:season" json:"season"`
IsActive bool `gorm:"column:is_active;default:false" json:"isActive"`
IsLoan bool `gorm:"column:is_loan;default:false" json:"isLoan"`
LoanDuration *int `gorm:"column:loan_duration" json:"loanDuration"`
HasOptionToBuy bool `gorm:"column:has_option_to_buy;default:false" json:"hasOptionToBuy"`
OptionToBuyFee *float64 `gorm:"column:option_to_buy_fee" json:"optionToBuyFee"`
AnnouncementDate *time.Time `gorm:"column:announcement_date" json:"announcementDate"`
SourceURL *string `gorm:"column:source_url" json:"sourceUrl"`
Type *string `gorm:"column:type" json:"type"`
Value *int `gorm:"column:value" json:"value"`
Currency *string `gorm:"column:currency" json:"currency"`
AnnounceDate *time.Time `gorm:"column:announce_date" json:"announceDate"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (pt *PlayerTransfer) BeforeCreate(tx *gorm.DB) (err error) {
if pt.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
pt.ID = id
return nil
}
type PlayerCareer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
PlayerWyID int `gorm:"column:player_wy_id;uniqueIndex:uidx_player_careers;index" json:"playerId"`
TeamID int `gorm:"column:team_id;uniqueIndex:uidx_player_careers;index" json:"teamId"`
SeasonID int `gorm:"column:season_id;uniqueIndex:uidx_player_careers;index" json:"seasonId"`
CompetitionID int `gorm:"column:competition_id;uniqueIndex:uidx_player_careers;index" json:"competitionId"`
ShirtNumber int `gorm:"column:shirt_number" json:"shirtNumber"`
Goal int `gorm:"column:goal" json:"goal"`
Penalties int `gorm:"column:penalties" json:"penalties"`
Appearances int `gorm:"column:appearances" json:"appearances"`
YellowCard int `gorm:"column:yellow_card" json:"yellowCard"`
RedCards int `gorm:"column:red_cards" json:"redCards"`
SubstituteIn int `gorm:"column:substitute_in" json:"substituteIn"`
SubstituteOut int `gorm:"column:substitute_out" json:"substituteOut"`
SubOnBench int `gorm:"column:substitute_on_bench" json:"substituteOnBench"`
MinutesPlayed int `gorm:"column:minutes_played" json:"minutesPlayed"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type TeamCareer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID int `gorm:"column:team_wy_id;uniqueIndex:uidx_team_careers;index" json:"teamWyId"`
SeasonID int `gorm:"column:season_id;uniqueIndex:uidx_team_careers;index" json:"seasonId"`
CompetitionID int `gorm:"column:competition_id;uniqueIndex:uidx_team_careers;index" json:"competitionId"`
RoundID int `gorm:"column:round_id;uniqueIndex:uidx_team_careers" json:"roundId"`
RoundName *string `gorm:"column:round_name" json:"roundName"`
GroupID int `gorm:"column:group_id;uniqueIndex:uidx_team_careers" json:"groupId"`
GroupName *string `gorm:"column:group_name" json:"groupName"`
Rank int `gorm:"column:rank" json:"rank"`
MatchTotal int `gorm:"column:match_total" json:"matchTotal"`
MatchWon int `gorm:"column:match_won" json:"matchWon"`
MatchDraw int `gorm:"column:match_draw" json:"matchDraw"`
MatchLost int `gorm:"column:match_lost" json:"matchLost"`
GoalPro int `gorm:"column:goal_pro" json:"goalPro"`
GoalAgainst int `gorm:"column:goal_against" json:"goalAgainst"`
Points int `gorm:"column:points" json:"points"`
SeasonJSON json.RawMessage `gorm:"column:season_json;type:jsonb" json:"season" swaggertype:"object"`
CompetitionJSON json.RawMessage `gorm:"column:competition_json;type:jsonb" json:"competition" swaggertype:"object"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (tc *TeamCareer) BeforeCreate(tx *gorm.DB) (err error) {
if tc.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
tc.ID = id
return nil
}
func (pc *PlayerCareer) BeforeCreate(tx *gorm.DB) (err error) {
if pc.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
pc.ID = id
return nil
}
type TeamSquad struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
......@@ -597,3 +673,17 @@ type Round struct {
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (r *Round) BeforeCreate(tx *gorm.DB) (err error) {
if r.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
r.ID = id
return nil
}
......@@ -3,11 +3,13 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type CoachService interface {
......@@ -39,11 +41,59 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(coaches.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"coaches.short_name ILIKE ? OR coaches.first_name ILIKE ? OR coaches.middle_name ILIKE ? OR coaches.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Add token-based search for multi-word queries
if len(searchTokens) > 1 {
// Intentionally do NOT OR tokens together.
// For multi-token input (e.g. "pablo rosario"), we require ALL tokens to match
// somewhere across the name fields.
}
query = query.Where(searchConditions)
if len(searchTokens) > 1 {
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
"LOWER(coaches.short_name) ILIKE ? OR LOWER(coaches.first_name) ILIKE ? OR LOWER(coaches.middle_name) ILIKE ? OR LOWER(coaches.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
}
// Join with teams to check for big competitions
query = query.Joins("LEFT JOIN teams ON teams.wy_id = coaches.current_team_wy_id")
query = query.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
// Prioritization logic for coaches
query = query.Order("coaches.is_active DESC")
query = query.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
query = query.Order("CASE WHEN coaches.current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN coaches.years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("coaches.years_experience DESC")
query = query.Order("coaches.last_name ASC")
query = query.Order("coaches.first_name ASC")
} else if opts.TeamID != "" {
query = query.Where("current_team_wy_id = ?", opts.TeamID)
} else if opts.Position != "" {
......@@ -54,18 +104,25 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query = query.Where("is_active = ?", true)
}
if opts.Name == "" {
query = query.Order("is_active DESC")
query = query.Order("CASE WHEN current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("years_experience DESC")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count coaches", err)
}
// Select only coach fields to avoid conflicts with joined tables
if opts.Name != "" {
query = query.Select("coaches.*")
}
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&coaches).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch coaches", err)
}
......
......@@ -3,11 +3,14 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type PlayerService interface {
......@@ -15,6 +18,9 @@ type PlayerService interface {
GetByID(ctx context.Context, id string) (models.Player, error)
GetByProviderID(ctx context.Context, wyID int) (models.Player, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Player, error)
GetByHudlID(ctx context.Context, hudlID string) (models.Player, error)
ListTransfersByHudlID(ctx context.Context, hudlID string) ([]models.PlayerTransfer, error)
ListCareerByHudlID(ctx context.Context, hudlID string) ([]models.PlayerCareer, error)
}
type ListPlayersOptions struct {
......@@ -23,6 +29,7 @@ type ListPlayersOptions struct {
Name string
TeamID string
Country string
Roles []string
}
type playerService struct {
......@@ -35,32 +42,136 @@ func NewPlayerService(db *gorm.DB) PlayerService {
func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error) {
var players []models.Player
query := s.db.WithContext(ctx).Model(&models.Player{}).Where("is_active = ?", true)
baseQuery := s.db.WithContext(ctx).Model(&models.Player{}).Where("players.is_active = ?", true)
if len(opts.Roles) > 0 {
roles := make([]string, 0, len(opts.Roles))
seen := make(map[string]struct{}, len(opts.Roles))
for _, r := range opts.Roles {
r = strings.TrimSpace(strings.ToLower(r))
if r == "" {
continue
}
if _, ok := seen[r]; ok {
continue
}
seen[r] = struct{}{}
roles = append(roles, r)
}
if len(roles) > 0 {
baseQuery = baseQuery.Where("LOWER(players.role_name) IN ?", roles)
}
}
if opts.Name != "" {
likePattern := "%" + opts.Name + "%"
query = query.Where(
"short_name ILIKE ? OR first_name ILIKE ? OR middle_name ILIKE ? OR last_name ILIKE ?",
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
// Using LOWER() and unaccent-like matching through normalized comparison
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC")
query = query.Order("CASE WHEN current_national_team_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN current_team_id IS NULL AND (team_ts_id IS NULL OR team_ts_id = '') THEN 1 ELSE 0 END")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"players.short_name ILIKE ? OR players.first_name ILIKE ? OR players.middle_name ILIKE ? OR players.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Build multi-token filter as:
// (phrase-like match across fields) OR (ALL tokens match somewhere across fields)
nameFilter := s.db.Where(searchConditions)
if len(searchTokens) > 1 {
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where(
"LOWER(players.short_name) ILIKE ? OR LOWER(players.first_name) ILIKE ? OR LOWER(players.middle_name) ILIKE ? OR LOWER(players.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
nameFilter = nameFilter.Or(tokenFilter)
}
baseQuery = baseQuery.Where(nameFilter)
// Always rank best name match first.
// This prevents a "big competition" result from outranking an exact name hit.
fetchQuery := baseQuery
fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " +
"WHEN LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 0 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 1 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || COALESCE(LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ', '') || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 2 " +
"WHEN LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 3 " +
"WHEN LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 4 " +
"ELSE 5 END",
Vars: []interface{}{normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch},
})
// Prioritization logic:
// 1. Players with current team (especially from big competitions)
// 2. Players with market value (indicates active/professional status)
// 3. Players with national team
// 4. Alphabetical by name
// Join with teams to check for big competitions
fetchQuery = fetchQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
fetchQuery = fetchQuery.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
// Tie-breakers after name match rank.
// Priority 1: Big competitions (top 5 leagues + Champions League, etc.)
// Competition WyIDs for major leagues:
// Premier League: 364, La Liga: 795, Bundesliga: 426, Serie A: 524, Ligue 1: 412
// Champions League: 102, Europa League: 103, etc.
fetchQuery = fetchQuery.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
// Priority 2: Has current team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END")
// Priority 3: Market value (higher is better)
fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NULL THEN 1 ELSE 0 END")
fetchQuery = fetchQuery.Order("players.market_value DESC")
// Priority 4: Has national team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_national_team_id IS NULL THEN 1 ELSE 0 END")
// Priority 5: Alphabetical
fetchQuery = fetchQuery.Order("players.last_name ASC")
fetchQuery = fetchQuery.Order("players.first_name ASC")
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err)
}
// Select only player fields to avoid conflicts with joined tables
fetchQuery = fetchQuery.Select("players.*")
if err := fetchQuery.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
}
return players, total, nil
} else if opts.TeamID != "" {
query = query.Where("current_team_id = ?", opts.TeamID)
baseQuery = baseQuery.Where("current_team_id = ?", opts.TeamID)
} else if opts.Country != "" {
query = query.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
baseQuery = baseQuery.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
}
var total int64
if err := query.Count(&total).Error; err != nil {
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err)
}
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
if err := baseQuery.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
}
......@@ -110,3 +221,83 @@ func (s *playerService) GetByProviderID(ctx context.Context, wyID int) (models.P
}
return player, nil
}
func (s *playerService) GetByHudlID(ctx context.Context, hudlID string) (models.Player, error) {
hudlID = strings.TrimSpace(hudlID)
if hudlID == "" {
return models.Player{}, errors.New(errors.CodeInvalidInput, "hudlId is required")
}
var player models.Player
if err := s.db.WithContext(ctx).First(&player, "uid = ?", hudlID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Player{}, errors.New(errors.CodeNotFound, "player not found")
}
return models.Player{}, errors.Wrap(errors.CodeInternal, "failed to fetch player", err)
}
return player, nil
}
func (s *playerService) listTransfersByPlayerWyID(ctx context.Context, playerWyID int) ([]models.PlayerTransfer, error) {
if playerWyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player wy_id is required")
}
var transfers []models.PlayerTransfer
if err := s.db.WithContext(ctx).
Where("player_wy_id = ?", playerWyID).
Order("start_date DESC").
Find(&transfers).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch player transfers", err)
}
return transfers, nil
}
func (s *playerService) listCareerByPlayerWyID(ctx context.Context, playerWyID int) ([]models.PlayerCareer, error) {
if playerWyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player wy_id is required")
}
var career []models.PlayerCareer
if err := s.db.WithContext(ctx).
Where("player_wy_id = ?", playerWyID).
Order("season_id DESC").
Find(&career).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch player career", err)
}
return career, nil
}
func (s *playerService) ListTransfersByHudlID(ctx context.Context, hudlID string) ([]models.PlayerTransfer, error) {
hudlID = strings.TrimSpace(hudlID)
player, err := s.GetByHudlID(ctx, hudlID)
if err != nil {
if errors.IsCode(err, errors.CodeNotFound) {
if wyID, convErr := strconv.Atoi(hudlID); convErr == nil && wyID > 0 {
return s.listTransfersByPlayerWyID(ctx, wyID)
}
}
return nil, err
}
if player.WyID == nil || *player.WyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player does not have a wy_id")
}
return s.listTransfersByPlayerWyID(ctx, *player.WyID)
}
func (s *playerService) ListCareerByHudlID(ctx context.Context, hudlID string) ([]models.PlayerCareer, error) {
hudlID = strings.TrimSpace(hudlID)
player, err := s.GetByHudlID(ctx, hudlID)
if err != nil {
if errors.IsCode(err, errors.CodeNotFound) {
if wyID, convErr := strconv.Atoi(hudlID); convErr == nil && wyID > 0 {
return s.listCareerByPlayerWyID(ctx, wyID)
}
}
return nil, err
}
if player.WyID == nil || *player.WyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player does not have a wy_id")
}
return s.listCareerByPlayerWyID(ctx, *player.WyID)
}
......@@ -3,11 +3,13 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type RefereeService interface {
......@@ -39,12 +41,54 @@ func (s *refereeService) ListReferees(ctx context.Context, opts ListRefereesOpti
query := s.db.WithContext(ctx).Model(&models.Referee{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(referees.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"referees.short_name ILIKE ? OR referees.first_name ILIKE ? OR referees.middle_name ILIKE ? OR referees.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Add token-based search for multi-word queries
if len(searchTokens) > 1 {
// Intentionally do NOT OR tokens together.
// For multi-token input (e.g. "pablo rosario"), we require ALL tokens to match
// somewhere across the name fields.
}
query = query.Where(searchConditions)
if len(searchTokens) > 1 {
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
"LOWER(referees.short_name) ILIKE ? OR LOWER(referees.first_name) ILIKE ? OR LOWER(referees.middle_name) ILIKE ? OR LOWER(referees.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
}
// Prioritization logic for referees
query = query.Order("referees.is_active DESC")
query = query.Order("CASE WHEN referees.experience_years IS NULL THEN 1 ELSE 0 END")
query = query.Order("referees.experience_years DESC")
query = query.Order("referees.last_name ASC")
query = query.Order("referees.first_name ASC")
}
if opts.CountryWyID != nil {
query = query.Where("nationality_wy_id = ?", *opts.CountryWyID)
}
......
package utils
import (
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// NormalizeText removes accents, converts to lowercase, and normalizes whitespace
func NormalizeText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Remove accents/diacritics
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
normalized, _, _ := transform.String(t, text)
// Normalize whitespace
normalized = strings.Join(strings.Fields(normalized), " ")
return normalized
}
// TokenizeSearchTerm splits a search term into tokens for better matching
func TokenizeSearchTerm(text string) []string {
normalized := NormalizeText(text)
tokens := strings.Fields(normalized)
return tokens
}
// CalculateNameMatchScore calculates a match score between search term and name fields
// Returns a score from 0-100, where higher is better
func CalculateNameMatchScore(searchTerm, shortName, firstName, middleName, lastName string) int {
normalizedSearch := NormalizeText(searchTerm)
normalizedShort := NormalizeText(shortName)
normalizedFirst := NormalizeText(firstName)
normalizedMiddle := NormalizeText(middleName)
normalizedLast := NormalizeText(lastName)
normalizedFull := strings.TrimSpace(normalizedFirst + " " + normalizedMiddle + " " + normalizedLast)
// Exact match on short name (highest priority)
if normalizedSearch == normalizedShort {
return 100
}
// Exact match on full name
if normalizedSearch == normalizedFull {
return 95
}
// Exact match on first + last
normalizedFirstLast := strings.TrimSpace(normalizedFirst + " " + normalizedLast)
if normalizedSearch == normalizedFirstLast {
return 90
}
// Exact match on last name
if normalizedSearch == normalizedLast {
return 85
}
// Exact match on first name
if normalizedSearch == normalizedFirst {
return 80
}
// Check if all search tokens are present in any name field
searchTokens := TokenizeSearchTerm(searchTerm)
if len(searchTokens) == 0 {
return 0
}
allTokensMatch := true
for _, token := range searchTokens {
found := false
if strings.Contains(normalizedShort, token) ||
strings.Contains(normalizedFirst, token) ||
strings.Contains(normalizedMiddle, token) ||
strings.Contains(normalizedLast, token) {
found = true
}
if !found {
allTokensMatch = false
break
}
}
if allTokensMatch {
// Score based on how many tokens matched
if len(searchTokens) >= 2 {
return 70
}
return 60
}
// Partial match - at least one token matches
for _, token := range searchTokens {
if strings.Contains(normalizedShort, token) {
return 50
}
if strings.Contains(normalizedLast, token) {
return 45
}
if strings.Contains(normalizedFirst, token) {
return 40
}
if strings.Contains(normalizedMiddle, token) {
return 35
}
}
// Check for prefix matches
for _, token := range searchTokens {
if strings.HasPrefix(normalizedLast, token) {
return 30
}
if strings.HasPrefix(normalizedFirst, token) {
return 25
}
}
return 0
}
// BuildSearchPattern creates a SQL ILIKE pattern from search term
func BuildSearchPattern(searchTerm string) string {
normalized := NormalizeText(searchTerm)
return "%" + normalized + "%"
}
-- Migration 0010: create player_transfers table for Wyscout player transfers
CREATE TABLE IF NOT EXISTS player_transfers (
id varchar(16) PRIMARY KEY,
transfer_id integer NOT NULL UNIQUE,
player_wy_id integer NOT NULL,
from_team_id integer,
from_team_name text,
to_team_id integer,
to_team_name text,
is_active boolean NOT NULL DEFAULT false,
start_date date,
end_date date,
type text,
value integer,
currency varchar(16),
announce_date date,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_player_transfers_player_wy_id ON player_transfers (player_wy_id);
CREATE INDEX IF NOT EXISTS idx_player_transfers_from_team_id ON player_transfers (from_team_id);
CREATE INDEX IF NOT EXISTS idx_player_transfers_to_team_id ON player_transfers (to_team_id);
-- Migration 0011: create player_careers table for Wyscout player career stats
CREATE TABLE IF NOT EXISTS player_careers (
id varchar(16) PRIMARY KEY,
player_wy_id integer NOT NULL,
team_id integer NOT NULL,
season_id integer NOT NULL,
competition_id integer NOT NULL,
shirt_number integer NOT NULL DEFAULT 0,
goal integer NOT NULL DEFAULT 0,
penalties integer NOT NULL DEFAULT 0,
appearances integer NOT NULL DEFAULT 0,
yellow_card integer NOT NULL DEFAULT 0,
red_cards integer NOT NULL DEFAULT 0,
substitute_in integer NOT NULL DEFAULT 0,
substitute_out integer NOT NULL DEFAULT 0,
substitute_on_bench integer NOT NULL DEFAULT 0,
minutes_played integer NOT NULL DEFAULT 0,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_player_careers UNIQUE (player_wy_id, team_id, season_id, competition_id)
);
CREATE INDEX IF NOT EXISTS idx_player_careers_player_wy_id ON player_careers (player_wy_id);
CREATE INDEX IF NOT EXISTS idx_player_careers_season_id ON player_careers (season_id);
CREATE INDEX IF NOT EXISTS idx_player_careers_team_id ON player_careers (team_id);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'round_type') THEN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'round_type' AND e.enumlabel = 'cup'
) THEN
ALTER TYPE round_type ADD VALUE 'cup';
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'round_type' AND e.enumlabel = 'table'
) THEN
ALTER TYPE round_type ADD VALUE 'table';
END IF;
END IF;
END $$;
-- Migration 0013: create team_careers table for Wyscout team career stats
CREATE TABLE IF NOT EXISTS team_careers (
id varchar(16) PRIMARY KEY,
team_wy_id integer NOT NULL,
season_id integer NOT NULL,
competition_id integer NOT NULL,
round_id integer NOT NULL DEFAULT 0,
round_name text,
group_id integer NOT NULL DEFAULT 0,
group_name text,
rank integer NOT NULL DEFAULT 0,
match_total integer NOT NULL DEFAULT 0,
match_won integer NOT NULL DEFAULT 0,
match_draw integer NOT NULL DEFAULT 0,
match_lost integer NOT NULL DEFAULT 0,
goal_pro integer NOT NULL DEFAULT 0,
goal_against integer NOT NULL DEFAULT 0,
points integer NOT NULL DEFAULT 0,
season_json jsonb,
competition_json jsonb,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_team_careers UNIQUE (team_wy_id, season_id, competition_id, round_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_team_careers_team_wy_id ON team_careers (team_wy_id);
CREATE INDEX IF NOT EXISTS idx_team_careers_season_id ON team_careers (season_id);
CREATE INDEX IF NOT EXISTS idx_team_careers_competition_id ON team_careers (competition_id);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment