package export import ( "database/sql" "encoding/json" "fmt" "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。 type Meta struct { EventName string Subtitle string Organizer string GeneratedAt time.Time GeneratedBy string } func (m Meta) normalized() Meta { if m.GeneratedAt.IsZero() { m.GeneratedAt = time.Now() } return m } // Options 是导出选项;零值即「正式成绩」口径。 type Options struct { Meta Meta // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。 // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 IncludeDrafts bool } // resolveMeta 读一次 settings,把留空的抬头字段补齐;顺带把设置表返回给调用方复用。 func resolveMeta(db *sql.DB, meta Meta) (Meta, map[string]string, error) { settings, err := loadSettings(db) if err != nil { return Meta{}, nil, err } if meta.EventName == "" { meta.EventName = settings["event.name"] } if meta.Subtitle == "" { meta.Subtitle = settings["event.subtitle"] } if meta.Organizer == "" { meta.Organizer = settings["event.organizer"] } return meta.normalized(), settings, nil } func loadSettings(db *sql.DB) (map[string]string, error) { rows, err := db.Query(`SELECT key, value FROM settings`) if err != nil { return nil, fmt.Errorf("export: 读取赛事设置: %w", err) } defer func() { _ = rows.Close() }() out := make(map[string]string) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, fmt.Errorf("export: 扫描赛事设置: %w", err) } out[k] = v } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历赛事设置: %w", err) } return out, nil } // LoadStandingsWorkbook 读取单位、运动员、积分流水与名次,交给 domain 算出三个榜。 // 排名规则只在 domain 里有一份,这里不做任何二次计算。 func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error) { if db == nil { return StandingsWorkbook{}, ErrNoDatabase } meta, settings, err := resolveMeta(db, opts.Meta) if err != nil { return StandingsWorkbook{}, err } teams, err := loadTeams(db) if err != nil { return StandingsWorkbook{}, err } athletes, err := loadAthletes(db) if err != nil { return StandingsWorkbook{}, err } ledger, err := loadLedger(db) if err != nil { return StandingsWorkbook{}, err } ranked, err := loadRanked(db) if err != nil { return StandingsWorkbook{}, err } in := domain.StandingsInput{ Teams: teams, Athletes: athletes, Ledger: ledger, Ranked: ranked, IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1), ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"), } return StandingsWorkbook{ Meta: meta, Team: domain.BuildTeamStandings(in), Medal: domain.BuildMedalStandings(in), Athlete: domain.BuildAthleteStandings(in), }, nil } func loadTeams(db *sql.DB) ([]domain.Team, error) { // 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。 rows, err := db.Query(` SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), COALESCE(color, ''), sort_order, active FROM teams WHERE active = 1 ORDER BY sort_order, code`) if err != nil { return nil, fmt.Errorf("export: 读取单位: %w", err) } defer func() { _ = rows.Close() }() var out []domain.Team for rows.Next() { var t domain.Team var active int if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil { return nil, fmt.Errorf("export: 扫描单位: %w", err) } t.Active = active != 0 out = append(out, t) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历单位: %w", err) } return out, nil } func loadAthletes(db *sql.DB) ([]domain.Athlete, error) { // 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。 rows, err := db.Query(` SELECT id, name, COALESCE(gender, ''), COALESCE(team_id, 0), COALESCE(employee_no, ''), active FROM athletes ORDER BY id`) if err != nil { return nil, fmt.Errorf("export: 读取运动员: %w", err) } defer func() { _ = rows.Close() }() var out []domain.Athlete for rows.Next() { var a domain.Athlete var active int if err := rows.Scan(&a.ID, &a.Name, &a.Gender, &a.TeamID, &a.EmployeeNo, &active); err != nil { return nil, fmt.Errorf("export: 扫描运动员: %w", err) } a.Active = active != 0 out = append(out, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历运动员: %w", err) } return out, nil } func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) { rows, err := db.Query(` SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0), COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points, voided, COALESCE(void_reason, '') FROM score_ledger ORDER BY id`) if err != nil { return nil, fmt.Errorf("export: 读取积分流水: %w", err) } defer func() { _ = rows.Close() }() var out []domain.LedgerEntry for rows.Next() { var e domain.LedgerEntry var voided int if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil { return nil, fmt.Errorf("export: 扫描积分流水: %w", err) } e.Voided = voided != 0 out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历积分流水: %w", err) } return out, nil } // loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。 // 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要 // 重算分值,也就能保证与正式计分结果完全一致。 func loadRanked(db *sql.DB) ([]domain.RankedResult, error) { scopes, err := loadChannelScopes(db) if err != nil { return nil, err } rows, err := db.Query(` SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id, COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value FROM results r JOIN entries en ON en.id = r.entry_id JOIN events e ON e.id = r.event_id LEFT JOIN athletes a ON a.id = en.athlete_id WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok' ORDER BY r.event_id, r.rank, r.id`) if err != nil { return nil, fmt.Errorf("export: 读取名次: %w", err) } defer func() { _ = rows.Close() }() var out []domain.RankedResult for rows.Next() { var ( rr domain.RankedResult resultValue sql.NullFloat64 ) if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil { return nil, fmt.Errorf("export: 扫描名次: %w", err) } if resultValue.Valid { v := resultValue.Float64 rr.ResultValue = &v } if rr.TeamID == 0 { continue } // 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚, // 因此按 scope 展开成多条明细。 for _, sc := range scopes[rr.EventID] { if sc == domain.ScopeAthlete && rr.AthleteID == 0 { continue } item := rr item.Scope = sc item.Basis = domain.BasisResult item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} out = append(out, item) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历名次: %w", err) } return out, nil } func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) { rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`) if err != nil { return nil, fmt.Errorf("export: 读取计分通道: %w", err) } defer func() { _ = rows.Close() }() out := make(map[int64][]domain.Scope) for rows.Next() { var eventID int64 var scope domain.Scope if err := rows.Scan(&eventID, &scope); err != nil { return nil, fmt.Errorf("export: 扫描计分通道: %w", err) } if !containsScope(out[eventID], scope) { out[eventID] = append(out[eventID], scope) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历计分通道: %w", err) } return out, nil } func containsScope(list []domain.Scope, scope domain.Scope) bool { for _, s := range list { if s == scope { return true } } return false } // eventInfo 是导出分项成绩表需要的项目信息。 type eventInfo struct { id int64 sportName string name string } // LoadEventWorkbook 读取一个小项(eventID=0 表示全部)的成绩,按组别拆成多个 sheet。 func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, error) { if db == nil { return EventsWorkbook{}, ErrNoDatabase } meta, _, err := resolveMeta(db, opts.Meta) if err != nil { return EventsWorkbook{}, err } events, err := loadEvents(db, eventID) if err != nil { return EventsWorkbook{}, err } if eventID != 0 && len(events) == 0 { return EventsWorkbook{}, fmt.Errorf("export: 小项 %d 不存在", eventID) } sheets := make([]EventSheet, 0, len(events)) for _, ev := range events { cats, err := loadCategories(db, ev.id) if err != nil { return EventsWorkbook{}, err } if len(cats) == 0 { rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts) if err != nil { return EventsWorkbook{}, err } sheets = append(sheets, EventSheet{SportName: ev.sportName, EventName: ev.name, Rows: rows}) continue } for _, cat := range cats { rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts) if err != nil { return EventsWorkbook{}, err } sheets = append(sheets, EventSheet{ SportName: ev.sportName, EventName: ev.name, Category: cat.name, Rows: rows, }) } } return EventsWorkbook{Meta: meta, Sheets: sheets}, nil } func loadEvents(db *sql.DB, eventID int64) ([]eventInfo, error) { query := ` SELECT e.id, COALESCE(s.name, ''), e.name FROM events e LEFT JOIN sports s ON s.id = e.sport_id` args := []any{} if eventID != 0 { query += ` WHERE e.id = ?` args = append(args, eventID) } query += ` ORDER BY s.sort_order, e.sort_order, e.id` rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("export: 读取小项: %w", err) } defer func() { _ = rows.Close() }() var out []eventInfo for rows.Next() { var ev eventInfo if err := rows.Scan(&ev.id, &ev.sportName, &ev.name); err != nil { return nil, fmt.Errorf("export: 扫描小项: %w", err) } out = append(out, ev) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历小项: %w", err) } return out, nil } type categoryInfo struct { id int64 name string } func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) { rows, err := db.Query(` SELECT id, name FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID) if err != nil { return nil, fmt.Errorf("export: 读取组别: %w", err) } defer func() { _ = rows.Close() }() var out []categoryInfo for rows.Next() { var c categoryInfo if err := rows.Scan(&c.id, &c.name); err != nil { return nil, fmt.Errorf("export: 扫描组别: %w", err) } out = append(out, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历组别: %w", err) } return out, nil } // loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) { query := ` SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''), COALESCE(r.result_text, ''), COALESCE(r.unit, ''), COALESCE((SELECT SUM(l.points) FROM score_ledger l WHERE l.entry_id = en.id AND l.voided = 0), 0), r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '') FROM entries en JOIN teams t ON t.id = en.team_id LEFT JOIN athletes a ON a.id = en.athlete_id LEFT JOIN results r ON r.entry_id = en.id WHERE en.event_id = ?` args := []any{eventID} if categoryID != 0 { query += ` AND en.category_id = ?` args = append(args, categoryID) } if !includeDrafts { query += ` AND (r.state IS NULL OR r.state = 'published')` } query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id` rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("export: 读取成绩: %w", err) } defer func() { _ = rows.Close() }() var out []EventResultRow for rows.Next() { var ( row EventResultRow rank, isTie int memberJSON, athleteName string resultStat, resultState sql.NullString entryStat, entryRemark string resultNote string ) if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName, &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, &entryRemark, &resultNote); err != nil { return nil, fmt.Errorf("export: 扫描成绩: %w", err) } row.Participant = participantName(athleteName, memberJSON) row.Rank = rankLabel(rank, isTie != 0) row.Status = statusLabel(resultStat, resultState, entryStat) row.Remark = joinRemark(entryRemark, resultNote) out = append(out, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历成绩: %w", err) } return out, nil } // LoadTeamDetailWorkbook 读取某单位未被作废的积分流水,逐条列出得分来源。 func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailWorkbook, error) { if db == nil { return TeamDetailWorkbook{}, ErrNoDatabase } meta, _, err := resolveMeta(db, opts.Meta) if err != nil { return TeamDetailWorkbook{}, err } team, err := loadTeam(db, teamID) if err != nil { return TeamDetailWorkbook{}, err } rows, err := loadTeamDetailRows(db, teamID) if err != nil { return TeamDetailWorkbook{}, err } return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil } func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) { var t domain.Team var active int err := db.QueryRow(` SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), COALESCE(color, ''), sort_order, active FROM teams WHERE id = ?`, teamID). Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active) if err == sql.ErrNoRows { return domain.Team{}, fmt.Errorf("%w: %d", ErrTeamNotFound, teamID) } if err != nil { return domain.Team{}, fmt.Errorf("export: 读取单位 %d: %w", teamID, err) } t.Active = active != 0 return t, nil } func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { rows, err := db.Query(` SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''), COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''), l.source_type, l.points, COALESCE(l.void_reason, '') FROM score_ledger l LEFT JOIN events e ON e.id = l.event_id LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN entries en ON en.id = l.entry_id LEFT JOIN event_categories c ON c.id = en.category_id LEFT JOIN results r ON r.id = l.result_id LEFT JOIN athletes a ON a.id = l.athlete_id WHERE l.team_id = ? AND l.voided = 0 ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID) if err != nil { return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err) } defer func() { _ = rows.Close() }() var out []TeamDetailRow for rows.Next() { var r TeamDetailRow var source string if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, &r.ResultText, &source, &r.Points, &r.Remark); err != nil { return nil, fmt.Errorf("export: 扫描积分明细: %w", err) } r.Source = sourceLabel(source) out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历积分明细: %w", err) } return out, nil } func rankLabel(rank int, isTie bool) string { if rank <= 0 { return "" } if isTie { return strconv.Itoa(rank) + "(并列)" } return strconv.Itoa(rank) } // statusLabel 把成绩状态翻成中文;没有成绩行时退回报名状态。 func statusLabel(resultStatus, resultState sql.NullString, entryStatus string) string { if !resultStatus.Valid { return entryStatusLabel(entryStatus) } if resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft { return "草稿" } switch domain.ResultStatus(resultStatus.String) { case domain.ResultStatusOK, "": return "有效" case domain.ResultStatusDQ: return "犯规" case domain.ResultStatusDNS: return "弃权" case domain.ResultStatusDNF: return "未完成" case domain.ResultStatusWithdrawn: return "退赛" default: return resultStatus.String } } // joinRemark 合并报名备注与成绩备注,两条都有时用分号隔开。 func joinRemark(entryRemark, resultNote string) string { entryRemark = strings.TrimSpace(entryRemark) resultNote = strings.TrimSpace(resultNote) switch { case entryRemark == "": return resultNote case resultNote == "": return entryRemark default: return entryRemark + ";" + resultNote } } func entryStatusLabel(status string) string { switch domain.EntryStatus(status) { case domain.EntryStatusRegistered, "": return "未录入" case domain.EntryStatusScratched: return "弃权" case domain.EntryStatusDQ: return "犯规" case domain.EntryStatusDNS: return "弃权" default: return status } } func sourceLabel(source string) string { switch domain.SourceType(source) { case domain.SourceResult: return "成绩" case domain.SourceRecordBonus: return "破纪录加分" case domain.SourceAdjustment: return "人工调整" default: return source } } func parseFloatDefault(raw string, def float64) float64 { v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) if err != nil { return def } return v } // participantName 取个人姓名;团体项目则把成员 JSON 数组拼成顿号列表。 func participantName(athleteName, memberJSON string) string { if strings.TrimSpace(athleteName) != "" { return athleteName } memberJSON = strings.TrimSpace(memberJSON) if memberJSON == "" { return "" } var names []string if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 { return strings.Join(names, "、") } return memberJSON }