mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
1302 lines
40 KiB
Go
1302 lines
40 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fotbal-club/internal/models"
|
|
"fotbal-club/internal/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/xuri/excelize/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ManualFACRAdminController manages manual competitions, matches and tables via CSV templates/imports.
|
|
type ManualFACRAdminController struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// NewManualFACRAdminController creates a new instance.
|
|
func NewManualFACRAdminController(db *gorm.DB) *ManualFACRAdminController {
|
|
return &ManualFACRAdminController{DB: db}
|
|
}
|
|
|
|
func (mc *ManualFACRAdminController) ensureManualMode(c *gin.Context) bool {
|
|
if !isManualClubDataMode() {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manual club data mode is required for this operation"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (mc *ManualFACRAdminController) getPrimaryClubSettings() (*models.Settings, error) {
|
|
var s models.Settings
|
|
if err := mc.DB.First(&s).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(s.ClubID) == "" || strings.TrimSpace(s.ClubType) == "" {
|
|
return nil, fmt.Errorf("primary club ID/type not configured in settings")
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// triggerManualPrefetchAsync triggers a background prefetch cycle so that
|
|
// /cache/prefetch/facr_club_info.json, facr_tables.json and matches.json
|
|
// are rebuilt immediately from the current data. This is especially useful
|
|
// in manual mode where we don't hit any external FACR API.
|
|
func (mc *ManualFACRAdminController) triggerManualPrefetchAsync() {
|
|
go func() {
|
|
base := getPrefetchBaseURL()
|
|
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
|
}()
|
|
}
|
|
|
|
// buildCompetitionLinks constructs canonical fotbal.cz links for a competition
|
|
// based on its external UUID. The format is:
|
|
//
|
|
// https://www.fotbal.cz/souteze/turnaje/hlavni/<uuid>
|
|
// https://www.fotbal.cz/souteze/turnaje/table/<uuid>
|
|
func buildCompetitionLinks(externalID string) (string, string) {
|
|
externalID = strings.TrimSpace(externalID)
|
|
if externalID == "" {
|
|
return "", ""
|
|
}
|
|
base := "https://www.fotbal.cz/souteze/turnaje"
|
|
matches := fmt.Sprintf("%s/hlavni/%s", base, externalID)
|
|
table := fmt.Sprintf("%s/table/%s", base, externalID)
|
|
return matches, table
|
|
}
|
|
|
|
// ----- Manual competitions CRUD -----
|
|
|
|
// ListManualCompetitions returns all manual competitions for the primary club.
|
|
func (mc *ManualFACRAdminController) ListManualCompetitions(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var comps []models.ManualCompetition
|
|
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
|
|
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competitions: %v", err)})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, comps)
|
|
}
|
|
|
|
type manualCompetitionPayload struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
ExternalID string `json:"external_id"`
|
|
MatchesLink string `json:"matches_link"`
|
|
TableLink string `json:"table_link"`
|
|
TeamCount string `json:"team_count"`
|
|
}
|
|
|
|
// CreateManualCompetition creates a new manual competition for the primary club.
|
|
func (mc *ManualFACRAdminController) CreateManualCompetition(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var body manualCompetitionPayload
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
body.Code = strings.TrimSpace(body.Code)
|
|
body.Name = strings.TrimSpace(body.Name)
|
|
body.ExternalID = strings.TrimSpace(body.ExternalID)
|
|
body.MatchesLink = strings.TrimSpace(body.MatchesLink)
|
|
body.TableLink = strings.TrimSpace(body.TableLink)
|
|
body.TeamCount = strings.TrimSpace(body.TeamCount)
|
|
if body.Code == "" || body.Name == "" || body.ExternalID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "code, name and external_id are required"})
|
|
return
|
|
}
|
|
|
|
// Allow admin to paste either plain UUID or full fotbal.cz link; always
|
|
// store canonical UUID and derive matches/table links from it.
|
|
uuid := extractUUIDFromHref(body.ExternalID)
|
|
if uuid == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "external_id must be a valid UUID or fotbal.cz competition URL containing UUID"})
|
|
return
|
|
}
|
|
body.ExternalID = uuid
|
|
body.MatchesLink, body.TableLink = buildCompetitionLinks(uuid)
|
|
|
|
var existing models.ManualCompetition
|
|
if err := mc.DB.Where("club_id = ? AND club_type = ? AND (code = ? OR external_id = ?)", clubID, clubType, body.Code, body.ExternalID).
|
|
First(&existing).Error; err == nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "competition with this code or external_id already exists"})
|
|
return
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to check existing competitions: %v", err)})
|
|
return
|
|
}
|
|
|
|
comp := models.ManualCompetition{
|
|
ClubID: clubID,
|
|
ClubType: clubType,
|
|
Code: body.Code,
|
|
Name: body.Name,
|
|
ExternalID: body.ExternalID,
|
|
MatchesLink: body.MatchesLink,
|
|
TableLink: body.TableLink,
|
|
TeamCount: body.TeamCount,
|
|
}
|
|
if err := mc.DB.Create(&comp).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create competition: %v", err)})
|
|
return
|
|
}
|
|
mc.triggerManualPrefetchAsync()
|
|
c.JSON(http.StatusCreated, comp)
|
|
}
|
|
|
|
// UpdateManualCompetition updates an existing manual competition.
|
|
func (mc *ManualFACRAdminController) UpdateManualCompetition(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
idStr := strings.TrimSpace(c.Param("id"))
|
|
if idStr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
|
return
|
|
}
|
|
idVal, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
|
|
var comp models.ManualCompetition
|
|
if err := mc.DB.First(&comp, uint(idVal)).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competition: %v", err)})
|
|
}
|
|
return
|
|
}
|
|
if strings.TrimSpace(comp.ClubID) != clubID || strings.TrimSpace(comp.ClubType) != clubType {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found for this club"})
|
|
return
|
|
}
|
|
|
|
var body manualCompetitionPayload
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
body.Code = strings.TrimSpace(body.Code)
|
|
body.Name = strings.TrimSpace(body.Name)
|
|
body.ExternalID = strings.TrimSpace(body.ExternalID)
|
|
body.MatchesLink = strings.TrimSpace(body.MatchesLink)
|
|
body.TableLink = strings.TrimSpace(body.TableLink)
|
|
body.TeamCount = strings.TrimSpace(body.TeamCount)
|
|
if body.Code == "" || body.Name == "" || body.ExternalID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "code, name and external_id are required"})
|
|
return
|
|
}
|
|
|
|
uuid := extractUUIDFromHref(body.ExternalID)
|
|
if uuid == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "external_id must be a valid UUID or fotbal.cz competition URL containing UUID"})
|
|
return
|
|
}
|
|
body.ExternalID = uuid
|
|
body.MatchesLink, body.TableLink = buildCompetitionLinks(uuid)
|
|
|
|
comp.Code = body.Code
|
|
comp.Name = body.Name
|
|
comp.ExternalID = body.ExternalID
|
|
comp.MatchesLink = body.MatchesLink
|
|
comp.TableLink = body.TableLink
|
|
comp.TeamCount = body.TeamCount
|
|
|
|
if err := mc.DB.Save(&comp).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update competition: %v", err)})
|
|
return
|
|
}
|
|
mc.triggerManualPrefetchAsync()
|
|
c.JSON(http.StatusOK, comp)
|
|
}
|
|
|
|
// DeleteManualCompetition deletes a manual competition and its matches/tables.
|
|
func (mc *ManualFACRAdminController) DeleteManualCompetition(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
idStr := strings.TrimSpace(c.Param("id"))
|
|
if idStr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
|
return
|
|
}
|
|
idVal, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
|
|
var comp models.ManualCompetition
|
|
if err := mc.DB.First(&comp, uint(idVal)).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competition: %v", err)})
|
|
}
|
|
return
|
|
}
|
|
if strings.TrimSpace(comp.ClubID) != clubID || strings.TrimSpace(comp.ClubType) != clubType {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found for this club"})
|
|
return
|
|
}
|
|
|
|
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualMatch{}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition matches: %v", err)})
|
|
return
|
|
}
|
|
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition table rows: %v", err)})
|
|
return
|
|
}
|
|
if err := mc.DB.Delete(&comp).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition: %v", err)})
|
|
return
|
|
}
|
|
mc.triggerManualPrefetchAsync()
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ----- CSV templates -----
|
|
|
|
func (mc *ManualFACRAdminController) buildCompetitionsSheetForTemplates(f *excelize.File, sheetName string) (string, error) {
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var comps []models.ManualCompetition
|
|
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
|
|
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if len(comps) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
f.NewSheet(sheetName)
|
|
f.SetCellValue(sheetName, "A1", "kod_souteze")
|
|
f.SetCellValue(sheetName, "B1", "nazev_souteze")
|
|
f.SetCellValue(sheetName, "C1", "id_souteze_uuid")
|
|
|
|
for i, comp := range comps {
|
|
row := i + 2
|
|
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), comp.Code)
|
|
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), comp.Name)
|
|
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), comp.ExternalID)
|
|
}
|
|
|
|
return fmt.Sprintf("%s!$A$2:$A$%d", sheetName, len(comps)+1), nil
|
|
}
|
|
|
|
func (mc *ManualFACRAdminController) buildTeamsSheetForTemplates(f *excelize.File, sheetName string) (string, error) {
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var comps []models.ManualCompetition
|
|
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
|
|
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if len(comps) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
type teamRow struct {
|
|
Code string
|
|
Name string
|
|
}
|
|
|
|
rows := make([]teamRow, 0)
|
|
seen := make(map[string]bool)
|
|
|
|
for _, comp := range comps {
|
|
var tableRows []models.ManualTableRow
|
|
if err := mc.DB.Where("competition_id = ?", comp.ID).
|
|
Order("team_name ASC, id ASC").Find(&tableRows).Error; err != nil {
|
|
return "", err
|
|
}
|
|
for _, tr := range tableRows {
|
|
name := strings.TrimSpace(tr.TeamName)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(comp.Code)) + "|" + strings.ToLower(name)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
rows = append(rows, teamRow{Code: comp.Code, Name: name})
|
|
}
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
f.NewSheet(sheetName)
|
|
f.SetCellValue(sheetName, "A1", "kod_souteze")
|
|
f.SetCellValue(sheetName, "B1", "nazev_tymu")
|
|
|
|
for i, r := range rows {
|
|
row := i + 2
|
|
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), r.Code)
|
|
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), r.Name)
|
|
}
|
|
|
|
return fmt.Sprintf("%s!$B$2:$B$%d", sheetName, len(rows)+1), nil
|
|
}
|
|
|
|
// GetMatchesTemplateCSV returns a CSV template for manual matches import.
|
|
func (mc *ManualFACRAdminController) GetMatchesTemplateCSV(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
f := excelize.NewFile()
|
|
matchesSheet := "Zápasy"
|
|
|
|
if name := f.GetSheetName(f.GetActiveSheetIndex()); name != matchesSheet {
|
|
_ = f.SetSheetName(name, matchesSheet)
|
|
}
|
|
|
|
headers := []string{
|
|
"kod_souteze",
|
|
"id_souteze_uuid",
|
|
"kolo",
|
|
"doma_venku",
|
|
"nazev_soupere",
|
|
"odkaz_klub_soupere",
|
|
"id_zapasu",
|
|
"datum",
|
|
"cas",
|
|
"vysledek",
|
|
"vysledek_polocas",
|
|
"odkaz_na_zapas",
|
|
"hriste",
|
|
}
|
|
for i, h := range headers {
|
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
|
f.SetCellValue(matchesSheet, cell, h)
|
|
}
|
|
|
|
competitionRange, _ := mc.buildCompetitionsSheetForTemplates(f, "Souteze")
|
|
if competitionRange != "" {
|
|
dv := &excelize.DataValidation{
|
|
Type: "list",
|
|
AllowBlank: true,
|
|
Formula1: competitionRange,
|
|
Sqref: "A2:A500",
|
|
}
|
|
if err := f.AddDataValidation(matchesSheet, dv); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
teamsRange, _ := mc.buildTeamsSheetForTemplates(f, "Tymy")
|
|
if teamsRange != "" {
|
|
// Filter team names by competition code from column A in the same row.
|
|
// Tymy sheet is grouped by kod_souteze, so MATCH+COUNTIF provide a contiguous range.
|
|
dv := &excelize.DataValidation{
|
|
Type: "list",
|
|
AllowBlank: true,
|
|
Formula1: "=OFFSET(Tymy!$B$1, MATCH($A2, Tymy!$A:$A, 0)-1, 0, COUNTIF(Tymy!$A:$A, $A2), 1)",
|
|
Sqref: "E2:E500",
|
|
}
|
|
if err := f.AddDataValidation(matchesSheet, dv); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
dvHomeAway := &excelize.DataValidation{
|
|
Type: "list",
|
|
AllowBlank: false,
|
|
Formula1: "\"doma,venku\"",
|
|
Sqref: "D2:D500",
|
|
}
|
|
if err := f.AddDataValidation(matchesSheet, dvHomeAway); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
c.Header("Content-Disposition", "attachment; filename=manual_matches_template.xlsx")
|
|
|
|
if err := f.Write(c.Writer); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write XLSX: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
// GetTablesTemplateCSV returns a CSV template for manual tables import.
|
|
func (mc *ManualFACRAdminController) GetTablesTemplateCSV(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
f := excelize.NewFile()
|
|
tablesSheet := "Tabulky"
|
|
|
|
if name := f.GetSheetName(f.GetActiveSheetIndex()); name != tablesSheet {
|
|
_ = f.SetSheetName(name, tablesSheet)
|
|
}
|
|
|
|
headers := []string{
|
|
"kod_souteze",
|
|
"id_souteze_uuid",
|
|
"poradi",
|
|
"nazev_tymu",
|
|
"odkaz_na_klub",
|
|
"zapasy",
|
|
"vyhry",
|
|
"remizy",
|
|
"prohry",
|
|
"skore",
|
|
"body",
|
|
}
|
|
for i, h := range headers {
|
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
|
f.SetCellValue(tablesSheet, cell, h)
|
|
}
|
|
|
|
competitionRange, _ := mc.buildCompetitionsSheetForTemplates(f, "Souteze")
|
|
if competitionRange != "" {
|
|
dv := &excelize.DataValidation{
|
|
Type: "list",
|
|
AllowBlank: true,
|
|
Formula1: competitionRange,
|
|
Sqref: "A2:A500",
|
|
}
|
|
if err := f.AddDataValidation(tablesSheet, dv); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
teamsRange, _ := mc.buildTeamsSheetForTemplates(f, "Tymy")
|
|
if teamsRange != "" {
|
|
// Filter team names by competition code from column A in the same row.
|
|
dv := &excelize.DataValidation{
|
|
Type: "list",
|
|
AllowBlank: true,
|
|
Formula1: "=OFFSET(Tymy!$B$1, MATCH($A2, Tymy!$A:$A, 0)-1, 0, COUNTIF(Tymy!$A:$A, $A2), 1)",
|
|
Sqref: "D2:D500",
|
|
}
|
|
if err := f.AddDataValidation(tablesSheet, dv); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
c.Header("Content-Disposition", "attachment; filename=manual_tables_template.xlsx")
|
|
|
|
if err := f.Write(c.Writer); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write XLSX: %v", err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
// ----- CSV import helpers -----
|
|
|
|
func normalizeHeader(s string) string {
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
func buildHeaderIndex(headers []string) map[string]int {
|
|
idx := make(map[string]int, len(headers)*2)
|
|
for i, h := range headers {
|
|
key := normalizeHeader(h)
|
|
idx[key] = i
|
|
|
|
// Czech CSV headers -> internal field keys
|
|
switch key {
|
|
case "kod_souteze":
|
|
idx["competition_code"] = i
|
|
case "id_souteze_uuid":
|
|
idx["competition_external_id"] = i
|
|
case "kolo":
|
|
idx["round"] = i
|
|
case "doma_venku":
|
|
idx["is_home"] = i
|
|
case "nazev_soupere":
|
|
idx["opponent_name"] = i
|
|
case "odkaz_klub_soupere":
|
|
idx["opponent_club_link"] = i
|
|
case "id_zapasu":
|
|
idx["external_match_id"] = i
|
|
case "datum":
|
|
idx["kickoff_date"] = i
|
|
case "cas":
|
|
idx["kickoff_time"] = i
|
|
case "vysledek":
|
|
idx["score_fulltime"] = i
|
|
case "vysledek_polocas":
|
|
idx["score_halftime"] = i
|
|
case "odkaz_na_zapas":
|
|
idx["match_link"] = i
|
|
case "hriste":
|
|
idx["venue"] = i
|
|
case "poznamka":
|
|
idx["note"] = i
|
|
case "poradi":
|
|
idx["rank"] = i
|
|
case "nazev_tymu":
|
|
idx["team_name"] = i
|
|
case "odkaz_na_klub":
|
|
idx["team_club_link"] = i
|
|
case "zapasy":
|
|
idx["played"] = i
|
|
case "vyhry":
|
|
idx["wins"] = i
|
|
case "remizy":
|
|
idx["draws"] = i
|
|
case "prohry":
|
|
idx["losses"] = i
|
|
case "skore":
|
|
idx["score"] = i
|
|
case "body":
|
|
idx["points"] = i
|
|
}
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func getCSVField(row []string, idx map[string]int, name string) string {
|
|
pos, ok := idx[normalizeHeader(name)]
|
|
if !ok || pos >= len(row) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(row[pos])
|
|
}
|
|
|
|
func parseIsHomeFlag(s string) (bool, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return false, fmt.Errorf("is_home is required (home/away or 1/0)")
|
|
}
|
|
switch s {
|
|
case "home", "h", "1", "true", "yes", "y", "doma", "domaci", "domácí", "d":
|
|
return true, nil
|
|
case "away", "a", "0", "false", "no", "n", "venku", "v":
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("invalid is_home value: %s", s)
|
|
}
|
|
}
|
|
|
|
// ImportMatchesCSV imports manual matches from a CSV file.
|
|
// Expected columns: competition_code, competition_external_id, round, is_home, opponent_name,
|
|
// opponent_club_link, external_match_id, kickoff_date (YYYY-MM-DD), kickoff_time (HH:MM),
|
|
// score_fulltime, score_halftime, match_link, venue, note.
|
|
func (mc *ManualFACRAdminController) ImportMatchesCSV(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
records, err := Exporter.ImportFromCSV(c, "file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read CSV: %v", err)})
|
|
return
|
|
}
|
|
if len(records) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty"})
|
|
return
|
|
}
|
|
|
|
headers := records[0]
|
|
idx := buildHeaderIndex(headers)
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var imported, updated int
|
|
var rowErrors []string
|
|
|
|
// Preload competitions cache by (code, external_id)
|
|
compByKey := map[string]*models.ManualCompetition{}
|
|
|
|
for rowIdx, row := range records[1:] {
|
|
lineNo := rowIdx + 2 // 1-based including header
|
|
// Skip completely empty rows
|
|
empty := true
|
|
for _, v := range row {
|
|
if strings.TrimSpace(v) != "" {
|
|
empty = false
|
|
break
|
|
}
|
|
}
|
|
if empty {
|
|
continue
|
|
}
|
|
|
|
compCode := getCSVField(row, idx, "competition_code")
|
|
compExtID := getCSVField(row, idx, "competition_external_id")
|
|
if compCode == "" && compExtID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition_code or competition_external_id is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
compKey := compCode + "|" + compExtID
|
|
comp := compByKey[compKey]
|
|
if comp == nil {
|
|
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
|
|
if compExtID != "" {
|
|
q = q.Where("external_id = ?", compExtID)
|
|
} else {
|
|
q = q.Where("code = ?", compCode)
|
|
}
|
|
var found models.ManualCompetition
|
|
if err := q.First(&found).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
|
|
continue
|
|
}
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading competition: %v", lineNo, err))
|
|
continue
|
|
}
|
|
comp = &found
|
|
compByKey[compKey] = comp
|
|
}
|
|
|
|
isHome, err := parseIsHomeFlag(getCSVField(row, idx, "is_home"))
|
|
if err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: %v", lineNo, err))
|
|
continue
|
|
}
|
|
|
|
opponentName := getCSVField(row, idx, "opponent_name")
|
|
oppLink := getCSVField(row, idx, "opponent_club_link")
|
|
oppExternalID := ""
|
|
if oppLink != "" {
|
|
oppExternalID = extractUUIDFromHref(oppLink)
|
|
}
|
|
|
|
matchID := getCSVField(row, idx, "external_match_id")
|
|
if matchID == "" {
|
|
if ml := getCSVField(row, idx, "match_link"); ml != "" {
|
|
matchID = extractUUIDFromHref(ml)
|
|
}
|
|
}
|
|
if matchID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: external_match_id or match_link with UUID is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
dateStr := getCSVField(row, idx, "kickoff_date")
|
|
timeStr := getCSVField(row, idx, "kickoff_time")
|
|
var kickoff time.Time
|
|
if dateStr != "" && timeStr != "" {
|
|
parsed, err := time.ParseInLocation("2006-01-02 15:04", dateStr+" "+timeStr, time.Local)
|
|
if err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: invalid kickoff date/time: %v", lineNo, err))
|
|
continue
|
|
}
|
|
kickoff = parsed
|
|
}
|
|
|
|
fullScore := getCSVField(row, idx, "score_fulltime")
|
|
htScore := getCSVField(row, idx, "score_halftime")
|
|
matchLink := getCSVField(row, idx, "match_link")
|
|
venue := getCSVField(row, idx, "venue")
|
|
note := getCSVField(row, idx, "note")
|
|
round := getCSVField(row, idx, "round")
|
|
|
|
var existing models.ManualMatch
|
|
q := mc.DB.Where("competition_id = ? AND external_match_id = ?", comp.ID, matchID)
|
|
err = q.First(&existing).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
// Create new
|
|
m := models.ManualMatch{
|
|
CompetitionID: comp.ID,
|
|
ExternalMatchID: matchID,
|
|
Round: round,
|
|
IsHome: isHome,
|
|
OpponentName: opponentName,
|
|
OpponentExternalID: oppExternalID,
|
|
OpponentURL: oppLink,
|
|
Kickoff: kickoff,
|
|
Score: fullScore,
|
|
HalftimeScore: htScore,
|
|
MatchURL: matchLink,
|
|
Venue: venue,
|
|
Note: note,
|
|
}
|
|
if err := mc.DB.Create(&m).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to create match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
imported++
|
|
} else {
|
|
// Update existing
|
|
existing.Round = round
|
|
existing.IsHome = isHome
|
|
existing.OpponentName = opponentName
|
|
existing.OpponentExternalID = oppExternalID
|
|
existing.OpponentURL = oppLink
|
|
existing.Kickoff = kickoff
|
|
existing.Score = fullScore
|
|
existing.HalftimeScore = htScore
|
|
existing.MatchURL = matchLink
|
|
existing.Venue = venue
|
|
existing.Note = note
|
|
if err := mc.DB.Save(&existing).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to update match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
}
|
|
|
|
if imported > 0 || updated > 0 {
|
|
mc.triggerManualPrefetchAsync()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"imported": imported,
|
|
"updated": updated,
|
|
"errors": rowErrors,
|
|
})
|
|
}
|
|
|
|
// ImportTablesCSV imports manual tables (standings) from a CSV file.
|
|
// Expected columns: competition_code, competition_external_id, rank, team_name,
|
|
// team_club_link, played, wins, draws, losses, score, points.
|
|
func (mc *ManualFACRAdminController) ImportTablesCSV(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
records, err := Exporter.ImportFromCSV(c, "file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read CSV: %v", err)})
|
|
return
|
|
}
|
|
if len(records) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty"})
|
|
return
|
|
}
|
|
|
|
headers := records[0]
|
|
idx := buildHeaderIndex(headers)
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var imported int
|
|
var rowErrors []string
|
|
|
|
// Cache competitions and track whether we've cleared existing rows per competition.
|
|
compByKey := map[string]*models.ManualCompetition{}
|
|
clearedTable := map[uint]bool{}
|
|
|
|
for rowIdx, row := range records[1:] {
|
|
lineNo := rowIdx + 2
|
|
// Skip completely empty rows
|
|
empty := true
|
|
for _, v := range row {
|
|
if strings.TrimSpace(v) != "" {
|
|
empty = false
|
|
break
|
|
}
|
|
}
|
|
if empty {
|
|
continue
|
|
}
|
|
|
|
compCode := getCSVField(row, idx, "competition_code")
|
|
compExtID := getCSVField(row, idx, "competition_external_id")
|
|
if compCode == "" && compExtID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition_code or competition_external_id is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
compKey := compCode + "|" + compExtID
|
|
comp := compByKey[compKey]
|
|
if comp == nil {
|
|
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
|
|
if compExtID != "" {
|
|
q = q.Where("external_id = ?", compExtID)
|
|
} else {
|
|
q = q.Where("code = ?", compCode)
|
|
}
|
|
var found models.ManualCompetition
|
|
if err := q.First(&found).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
|
|
continue
|
|
}
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading competition: %v", lineNo, err))
|
|
continue
|
|
}
|
|
comp = &found
|
|
compByKey[compKey] = comp
|
|
}
|
|
|
|
// On first row for this competition, clear previous table rows to fully replace.
|
|
if !clearedTable[comp.ID] {
|
|
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to clear existing table rows: %v", lineNo, err))
|
|
continue
|
|
}
|
|
clearedTable[comp.ID] = true
|
|
}
|
|
|
|
rank := getCSVField(row, idx, "rank")
|
|
teamName := getCSVField(row, idx, "team_name")
|
|
teamLink := getCSVField(row, idx, "team_club_link")
|
|
teamExtID := ""
|
|
if teamLink != "" {
|
|
teamExtID = extractUUIDFromHref(teamLink)
|
|
}
|
|
played := getCSVField(row, idx, "played")
|
|
wins := getCSVField(row, idx, "wins")
|
|
draws := getCSVField(row, idx, "draws")
|
|
losses := getCSVField(row, idx, "losses")
|
|
score := getCSVField(row, idx, "score")
|
|
points := getCSVField(row, idx, "points")
|
|
|
|
rowModel := models.ManualTableRow{
|
|
CompetitionID: comp.ID,
|
|
Rank: rank,
|
|
TeamName: teamName,
|
|
ExternalTeamID: teamExtID,
|
|
Played: played,
|
|
Wins: wins,
|
|
Draws: draws,
|
|
Losses: losses,
|
|
Score: score,
|
|
Points: points,
|
|
}
|
|
|
|
if err := mc.DB.Create(&rowModel).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to create table row: %v", lineNo, err))
|
|
continue
|
|
}
|
|
imported++
|
|
}
|
|
|
|
if imported > 0 {
|
|
mc.triggerManualPrefetchAsync()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"imported": imported,
|
|
"errors": rowErrors,
|
|
})
|
|
}
|
|
|
|
type manualMatchJSON struct {
|
|
CompetitionCode string `json:"competition_code"`
|
|
CompetitionExternalID string `json:"competition_external_id"`
|
|
Round string `json:"round"`
|
|
IsHome string `json:"is_home"`
|
|
OpponentName string `json:"opponent_name"`
|
|
OpponentClubLink string `json:"opponent_club_link"`
|
|
ExternalMatchID string `json:"external_match_id"`
|
|
KickoffDate string `json:"kickoff_date"`
|
|
KickoffTime string `json:"kickoff_time"`
|
|
KickoffISO string `json:"kickoff"`
|
|
ScoreFulltime string `json:"score_fulltime"`
|
|
ScoreHalftime string `json:"score_halftime"`
|
|
MatchLink string `json:"match_link"`
|
|
Venue string `json:"venue"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// ImportMatchesJSON imports manual matches from a JSON payload.
|
|
// The JSON shape mirrors the CSV columns used by ImportMatchesCSV, wrapped in an "items" array.
|
|
func (mc *ManualFACRAdminController) ImportMatchesJSON(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Items []manualMatchJSON `json:"items"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(body.Items) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON body must contain non-empty 'items' array"})
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var imported, updated int
|
|
var rowErrors []string
|
|
|
|
// Preload competitions cache by (code, external_id)
|
|
compByKey := map[string]*models.ManualCompetition{}
|
|
|
|
for idx, item := range body.Items {
|
|
lineNo := idx + 1
|
|
// Skip completely empty items
|
|
if strings.TrimSpace(item.CompetitionCode) == "" &&
|
|
strings.TrimSpace(item.CompetitionExternalID) == "" &&
|
|
strings.TrimSpace(item.Round) == "" &&
|
|
strings.TrimSpace(item.OpponentName) == "" &&
|
|
strings.TrimSpace(item.OpponentClubLink) == "" &&
|
|
strings.TrimSpace(item.ExternalMatchID) == "" &&
|
|
strings.TrimSpace(item.MatchLink) == "" {
|
|
continue
|
|
}
|
|
|
|
compCode := strings.TrimSpace(item.CompetitionCode)
|
|
compExtID := strings.TrimSpace(item.CompetitionExternalID)
|
|
if compCode == "" && compExtID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition_code or competition_external_id is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
compKey := compCode + "|" + compExtID
|
|
comp := compByKey[compKey]
|
|
if comp == nil {
|
|
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
|
|
if compExtID != "" {
|
|
q = q.Where("external_id = ?", compExtID)
|
|
} else {
|
|
q = q.Where("code = ?", compCode)
|
|
}
|
|
var found models.ManualCompetition
|
|
if err := q.First(&found).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
|
|
continue
|
|
}
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading competition: %v", lineNo, err))
|
|
continue
|
|
}
|
|
comp = &found
|
|
compByKey[compKey] = comp
|
|
}
|
|
|
|
isHome, err := parseIsHomeFlag(item.IsHome)
|
|
if err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: %v", lineNo, err))
|
|
continue
|
|
}
|
|
|
|
opponentName := strings.TrimSpace(item.OpponentName)
|
|
oppLink := strings.TrimSpace(item.OpponentClubLink)
|
|
oppExternalID := ""
|
|
if oppLink != "" {
|
|
oppExternalID = extractUUIDFromHref(oppLink)
|
|
}
|
|
|
|
matchID := strings.TrimSpace(item.ExternalMatchID)
|
|
if matchID == "" && strings.TrimSpace(item.MatchLink) != "" {
|
|
matchID = extractUUIDFromHref(item.MatchLink)
|
|
}
|
|
if matchID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: external_match_id or match_link with UUID is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
var kickoff time.Time
|
|
iso := strings.TrimSpace(item.KickoffISO)
|
|
dateStr := strings.TrimSpace(item.KickoffDate)
|
|
timeStr := strings.TrimSpace(item.KickoffTime)
|
|
if iso != "" {
|
|
parsed, err2 := time.Parse(time.RFC3339, iso)
|
|
if err2 != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: invalid kickoff (RFC3339): %v", lineNo, err2))
|
|
continue
|
|
}
|
|
kickoff = parsed
|
|
} else if dateStr != "" && timeStr != "" {
|
|
parsed, err2 := time.ParseInLocation("2006-01-02 15:04", dateStr+" "+timeStr, time.Local)
|
|
if err2 != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: invalid kickoff date/time: %v", lineNo, err2))
|
|
continue
|
|
}
|
|
kickoff = parsed
|
|
}
|
|
|
|
fullScore := strings.TrimSpace(item.ScoreFulltime)
|
|
htScore := strings.TrimSpace(item.ScoreHalftime)
|
|
matchLink := strings.TrimSpace(item.MatchLink)
|
|
venue := strings.TrimSpace(item.Venue)
|
|
note := strings.TrimSpace(item.Note)
|
|
round := strings.TrimSpace(item.Round)
|
|
|
|
var existing models.ManualMatch
|
|
q := mc.DB.Where("competition_id = ? AND external_match_id = ?", comp.ID, matchID)
|
|
err = q.First(&existing).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
m := models.ManualMatch{
|
|
CompetitionID: comp.ID,
|
|
ExternalMatchID: matchID,
|
|
Round: round,
|
|
IsHome: isHome,
|
|
OpponentName: opponentName,
|
|
OpponentExternalID: oppExternalID,
|
|
OpponentURL: oppLink,
|
|
Kickoff: kickoff,
|
|
Score: fullScore,
|
|
HalftimeScore: htScore,
|
|
MatchURL: matchLink,
|
|
Venue: venue,
|
|
Note: note,
|
|
}
|
|
if err := mc.DB.Create(&m).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to create match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
imported++
|
|
} else {
|
|
existing.Round = round
|
|
existing.IsHome = isHome
|
|
existing.OpponentName = opponentName
|
|
existing.OpponentExternalID = oppExternalID
|
|
existing.OpponentURL = oppLink
|
|
existing.Kickoff = kickoff
|
|
existing.Score = fullScore
|
|
existing.HalftimeScore = htScore
|
|
existing.MatchURL = matchLink
|
|
existing.Venue = venue
|
|
existing.Note = note
|
|
if err := mc.DB.Save(&existing).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to update match: %v", lineNo, err))
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
}
|
|
|
|
if imported > 0 || updated > 0 {
|
|
mc.triggerManualPrefetchAsync()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"imported": imported,
|
|
"updated": updated,
|
|
"errors": rowErrors,
|
|
})
|
|
}
|
|
|
|
type manualTableRowJSON struct {
|
|
CompetitionCode string `json:"competition_code"`
|
|
CompetitionExternalID string `json:"competition_external_id"`
|
|
Rank string `json:"rank"`
|
|
TeamName string `json:"team_name"`
|
|
TeamClubLink string `json:"team_club_link"`
|
|
Played string `json:"played"`
|
|
Wins string `json:"wins"`
|
|
Draws string `json:"draws"`
|
|
Losses string `json:"losses"`
|
|
Score string `json:"score"`
|
|
Points string `json:"points"`
|
|
}
|
|
|
|
// ImportTablesJSON imports manual tables from a JSON payload.
|
|
// The JSON shape mirrors the CSV columns used by ImportTablesCSV, wrapped in an "items" array.
|
|
func (mc *ManualFACRAdminController) ImportTablesJSON(c *gin.Context) {
|
|
if !mc.ensureManualMode(c) {
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Items []manualTableRowJSON `json:"items"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(body.Items) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON body must contain non-empty 'items' array"})
|
|
return
|
|
}
|
|
|
|
settings, err := mc.getPrimaryClubSettings()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
|
|
return
|
|
}
|
|
clubID := strings.TrimSpace(settings.ClubID)
|
|
clubType := strings.TrimSpace(settings.ClubType)
|
|
|
|
var imported int
|
|
var rowErrors []string
|
|
|
|
// Cache competitions and track whether we've cleared existing rows per competition.
|
|
compByKey := map[string]*models.ManualCompetition{}
|
|
clearedTable := map[uint]bool{}
|
|
|
|
for idx, item := range body.Items {
|
|
lineNo := idx + 1
|
|
// Skip completely empty items
|
|
if strings.TrimSpace(item.CompetitionCode) == "" &&
|
|
strings.TrimSpace(item.CompetitionExternalID) == "" &&
|
|
strings.TrimSpace(item.Rank) == "" &&
|
|
strings.TrimSpace(item.TeamName) == "" {
|
|
continue
|
|
}
|
|
|
|
compCode := strings.TrimSpace(item.CompetitionCode)
|
|
compExtID := strings.TrimSpace(item.CompetitionExternalID)
|
|
if compCode == "" && compExtID == "" {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition_code or competition_external_id is required", lineNo))
|
|
continue
|
|
}
|
|
|
|
compKey := compCode + "|" + compExtID
|
|
comp := compByKey[compKey]
|
|
if comp == nil {
|
|
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
|
|
if compExtID != "" {
|
|
q = q.Where("external_id = ?", compExtID)
|
|
} else {
|
|
q = q.Where("code = ?", compCode)
|
|
}
|
|
var found models.ManualCompetition
|
|
if err := q.First(&found).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
|
|
continue
|
|
}
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading competition: %v", lineNo, err))
|
|
continue
|
|
}
|
|
comp = &found
|
|
compByKey[compKey] = comp
|
|
}
|
|
|
|
// On first row for this competition, clear previous table rows to fully replace.
|
|
if !clearedTable[comp.ID] {
|
|
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to clear existing table rows: %v", lineNo, err))
|
|
continue
|
|
}
|
|
clearedTable[comp.ID] = true
|
|
}
|
|
|
|
teamName := strings.TrimSpace(item.TeamName)
|
|
teamLink := strings.TrimSpace(item.TeamClubLink)
|
|
teamExtID := ""
|
|
if teamLink != "" {
|
|
teamExtID = extractUUIDFromHref(teamLink)
|
|
}
|
|
played := strings.TrimSpace(item.Played)
|
|
wins := strings.TrimSpace(item.Wins)
|
|
draws := strings.TrimSpace(item.Draws)
|
|
losses := strings.TrimSpace(item.Losses)
|
|
score := strings.TrimSpace(item.Score)
|
|
points := strings.TrimSpace(item.Points)
|
|
|
|
rowModel := models.ManualTableRow{
|
|
CompetitionID: comp.ID,
|
|
Rank: strings.TrimSpace(item.Rank),
|
|
TeamName: teamName,
|
|
ExternalTeamID: teamExtID,
|
|
Played: played,
|
|
Wins: wins,
|
|
Draws: draws,
|
|
Losses: losses,
|
|
Score: score,
|
|
Points: points,
|
|
}
|
|
|
|
if err := mc.DB.Create(&rowModel).Error; err != nil {
|
|
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to create table row: %v", lineNo, err))
|
|
continue
|
|
}
|
|
imported++
|
|
}
|
|
|
|
if imported > 0 {
|
|
mc.triggerManualPrefetchAsync()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"imported": imported,
|
|
"errors": rowErrors,
|
|
})
|
|
}
|