口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
680 lines
20 KiB
Go
680 lines
20 KiB
Go
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
|
||
}
|
||
|
||
// 排名输入只含启用单位,与 REST 一致。
|
||
teams, err := loadTeams(db, true)
|
||
if err != nil {
|
||
return StandingsWorkbook{}, err
|
||
}
|
||
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
|
||
allTeams, err := loadTeams(db, false)
|
||
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,
|
||
TieBreak: splitSettingList(settings["standings.tie_break"]),
|
||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
|
||
}
|
||
team := domain.BuildTeamStandings(in)
|
||
medal := domain.BuildMedalStandings(in)
|
||
athlete := domain.BuildAthleteStandings(in)
|
||
fillTeamDisplay(team, medal, athlete, allTeams)
|
||
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
|
||
}
|
||
|
||
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
|
||
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
|
||
query := `
|
||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||
COALESCE(color, ''), sort_order, active
|
||
FROM teams`
|
||
if activeOnly {
|
||
query += ` WHERE active = 1`
|
||
}
|
||
query += ` ORDER BY sort_order, code`
|
||
|
||
rows, err := db.Query(query)
|
||
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
|
||
}
|
||
|
||
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
|
||
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
|
||
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
|
||
byID := make(map[int64]domain.Team, len(teams))
|
||
for _, t := range teams {
|
||
byID[t.ID] = t
|
||
}
|
||
for i := range team {
|
||
if t, ok := byID[team[i].TeamID]; ok {
|
||
if team[i].Code == "" {
|
||
team[i].Code = t.Code
|
||
}
|
||
if team[i].Name == "" {
|
||
team[i].Name = t.Name
|
||
}
|
||
if team[i].ShortName == "" {
|
||
team[i].ShortName = t.ShortName
|
||
}
|
||
}
|
||
}
|
||
for i := range medal {
|
||
if t, ok := byID[medal[i].TeamID]; ok {
|
||
if medal[i].Code == "" {
|
||
medal[i].Code = t.Code
|
||
}
|
||
if medal[i].Name == "" {
|
||
medal[i].Name = t.Name
|
||
}
|
||
if medal[i].ShortName == "" {
|
||
medal[i].ShortName = t.ShortName
|
||
}
|
||
}
|
||
}
|
||
for i := range athlete {
|
||
if t, ok := byID[athlete[i].TeamID]; ok {
|
||
if athlete[i].TeamCode == "" {
|
||
athlete[i].TeamCode = t.Code
|
||
}
|
||
if athlete[i].TeamName == "" {
|
||
athlete[i].TeamName = t.Name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
|
||
func splitSettingList(raw string) []string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(raw, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
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, ''), count_to_team
|
||
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
|
||
voided int
|
||
countToTeam 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, &countToTeam); err != nil {
|
||
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
|
||
}
|
||
e.Voided = voided != 0
|
||
e.CountToTeam = countToTeam != 0
|
||
out = append(out, e)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("export: 遍历积分流水: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||
rows, err := db.Query(`
|
||
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
|
||
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||
FROM results r
|
||
JOIN entries en ON en.id = r.entry_id
|
||
JOIN events ev ON ev.id = r.event_id
|
||
JOIN event_score_channels c ON c.event_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 r.status = 'ok'
|
||
AND ev.scored = 1
|
||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
|
||
ORDER BY r.id, c.sort_order, c.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
|
||
value sql.NullFloat64
|
||
athleteID sql.NullInt64
|
||
rank int
|
||
)
|
||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||
}
|
||
rr.Rank = rank
|
||
if value.Valid {
|
||
v := value.Float64
|
||
rr.ResultValue = &v
|
||
}
|
||
rr.AthleteID = athleteID.Int64
|
||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||
out = append(out, rr)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("export: 遍历名次: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// 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, settings, 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,
|
||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||
}, 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, ''), COALESCE(l.athlete_id, 0)
|
||
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 AND l.count_to_team = 1
|
||
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
|
||
var athleteID int64
|
||
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
|
||
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
|
||
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
|
||
}
|
||
r.Source = sourceLabel(source)
|
||
r.individual = athleteID != 0
|
||
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
|
||
}
|