package export import ( "context" "database/sql" "encoding/json" "fmt" "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" ) // Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。 type Meta struct { EventName string Subtitle string Organizer string GeneratedAt time.Time // GeneratedBy 是「导出人 XXX」那一行的取值。当前没有任何生产写入口(所有调用方都只填 EventName 等), // 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。 // 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。 GeneratedBy string // Note 是附在副标题后面的口径说明,由 Load* 按数据内容补(当前只有「本赛事含联队」时才会写: // 奖牌每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数)。 // 留空时副标题与改动前逐字一致——无联队的老导出件不能因为加了这个字段而变形。 Note string } func (m Meta) normalized() Meta { if m.GeneratedAt.IsZero() { m.GeneratedAt = time.Now() } return m } // Options 是导出选项;零值即「正式成绩」口径。 type Options struct { Meta Meta // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出; // 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。 // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 // // 当前没有生产写入口:HTTP 层所有导出 handler 都传 Options{},只有单测(load_test.go 的 // TestEventRowsIncludeDraftsKeepsDraftValues)设过 true,所以这个分支在生产不可达(代码卫生项 C30)。 // 保留它是因为「草稿值不外露」的口径必须有对照面才能被测住;真要接线请先想清楚 // 「谁能看到别人的草稿值」这个权限问题(现在连只读接口都不外露草稿)。 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) // 含联队时在副标题上写清奖牌口径:每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数。 // 合计行本身不改(那是定稿口径),只把那个数字解释清楚。 joints, err := loadJointTeams(db) if err != nil { return StandingsWorkbook{}, err } if len(joints) > 0 { meta.Note = jointMedalNote } 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 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。 // 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次, // 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。 // 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals), // domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。 func loadRanked(db *sql.DB) ([]domain.RankedResult, error) { rows, err := db.Query(` SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status, COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0), COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key, ev.award_medals, c.count_to_medals, c.count_to_team_medals FROM channel_ranks cr JOIN events ev ON ev.id = cr.event_id JOIN event_score_channels c ON c.id = cr.channel_id LEFT JOIN athletes a ON a.id = cr.athlete_id WHERE ev.scored = 1 AND cr.rank > 0 ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.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 status sql.NullString awardMedals int countToMedals int countToTeamMed int ) if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status, &rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID, &rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMed); err != nil { return nil, fmt.Errorf("export: 扫描名次: %w", err) } rr.Status = domain.ResultStatus(status.String) if value.Valid { v := value.Float64 rr.ResultValue = &v } rr.AwardMedals = awardMedals != 0 rr.CountToMedals = countToMedals != 0 rr.CountToTeamMedals = countToTeamMed != 0 out = append(out, rr) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历名次: %w", err) } // 归属份额按报名项配置补(联队:一条成绩归多个单位):与 REST 的 buildRanked、 // 快照的 loadRanked 同口径。没有报名项的名次与没配份额的老数据都退回「主单位独占 100%」, // team_id 为空时不造份额,免得给 0 号单位凭空记牌。 shares, err := loadEntryTeamShares(db) if err != nil { return nil, err } for i := range out { out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID) } return out, nil } // loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。 // 与 HTTP 侧的 loadEntryTeamSharesQ 同一份表、同一套排序:展示与流水顺序要稳定。 // // 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库 // 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。 func loadEntryTeamShares(db *sql.DB) (map[int64][]domain.TeamShare, error) { var n int if err := db.QueryRow( `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil { return nil, fmt.Errorf("export: 查询报名份额表: %w", err) } if n == 0 { return nil, nil } rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share FROM entry_team_shares ORDER BY entry_id, sort_order, team_id`) if err != nil { return nil, fmt.Errorf("export: 读取报名份额: %w", err) } defer func() { _ = rows.Close() }() out := make(map[int64][]domain.TeamShare) for rows.Next() { var ( entryID int64 s domain.TeamShare medal int ) if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil { return nil, fmt.Errorf("export: 扫描报名份额: %w", err) } if medal == 0 { excluded := false s.Medal = &excluded } out[entryID] = append(out[entryID], s) } return out, rows.Err() } // 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)) // 联队配置与队员关联各读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。 joints, err := loadJointTeams(db) if err != nil { return EventsWorkbook{}, err } members, err := loadEntryMembers(db) if err != nil { return EventsWorkbook{}, err } for _, ev := range events { cats, err := loadCategories(db, ev.id) if err != nil { return EventsWorkbook{}, err } if len(cats) == 0 { rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members) 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 := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members) 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 表示不限)的报名与成绩。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 // includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); // 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。 // // 队员关联在这里自己读一次。LoadEventWorkbook 逐张小项 / 组别表地调它,所以那边走的是 // loadEventRowsWithMembers(读一次给全部表复用),这条只是给单表调用与既有用例留的入口。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) { members, err := loadEntryMembers(db) if err != nil { return nil, err } return loadEventRowsWithMembers(db, eventID, categoryID, includeDrafts, joints, members) } // loadEventRowsWithMembers 是 loadEventRows 的本体:队员关联由调用方读好传进来。 func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam, members map[int64][]entryMember) ([]EventResultRow, error) { // 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次, // 口径 = scope=athlete 优先,其次通道 sort_order 小者。 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // 落不了库)回落到 results.rank,保持名次列不空白。 // // ⚠️ 这段子查询与 realtime/snapshot.go 的 mainChannelRankJoin、httpapi/live_events.go 的同名常量 // 同源(大屏两个读者),改一处要改三处。优先级**故意不同**:导出这里是 cr 优先(既有口径, // 由 TestEventRowsUseMainChannelRank 钉住),大屏那边是 results.rank 优先——「只更新名次」的发布 // 只写 results.rank、不重建 channel_ranks,大屏 cr 一优先就会显示上一次的旧名次。 // // 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN: // 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份 // 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。 // 取最小 id = 报名项那一份(引擎先产出它),名次值与升级前完全一致; // 老数据每 (通道, 组别, 报名项) 最多一行,两种写法逐字节等价。 // // gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源: // 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩), // 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。 // 延迟结算(standings.live_on_publish=false)下两者不等就说明这一组还没结算, // 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。 query := ` SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''), 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, ''), r.result_value IS NOT NULL, COALESCE(gs.required, 0), COALESCE(gs.published, 0), en.id 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 LEFT JOIN event_score_channels mc ON mc.id = ( SELECT c2.id FROM event_score_channels c2 WHERE c2.event_id = en.event_id ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id LIMIT 1) LEFT JOIN channel_ranks cr ON cr.id = ( SELECT MIN(cr2.id) FROM channel_ranks cr2 WHERE cr2.channel_id = mc.id AND cr2.entry_id = en.id AND cr2.category_id = en.category_id) LEFT JOIN ( SELECT e2.event_id, e2.category_id, COUNT(*) AS required, SUM(CASE WHEN r2.state = 'published' AND (r2.result_value IS NOT NULL OR r2.rank IS NOT NULL OR r2.status <> 'ok') THEN 1 ELSE 0 END) AS published FROM entries e2 LEFT JOIN results r2 ON r2.entry_id = e2.id WHERE e2.event_id = ? AND e2.status = 'registered' GROUP BY e2.event_id, e2.category_id ) gs ON gs.event_id = en.event_id AND gs.category_id = en.category_id WHERE en.event_id = ?` // 子查询里那个 ? 是同一项(先给 gs 的 event_id,再给外层 WHERE)。 args := []any{eventID, eventID} if categoryID != 0 { query += ` AND en.category_id = ?` args = append(args, categoryID) } // 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。 // 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行, // 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。 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 squad string resultStat, resultState sql.NullString entryStat, entryRemark string resultNote string hasValue int required, published int entryID int64 ) if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &squad, &athleteName, &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, &entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil { return nil, fmt.Errorf("export: 扫描成绩: %w", err) } row.Participant = participantLabel(athleteName, memberJSON, members[entryID]) row.Rank = rankLabel(rank, isTie != 0) row.Status = statusLabel(resultStat, resultState, entryStat) row.Remark = joinRemark(entryRemark, resultNote) // 联队:单位列写全成员单位(「第一分公司+第二分公司联队」),备注里写清分给了谁、各多少。 // 普通报名(含迁移回填的单条独占份额)不走这里,单位名与备注与改动前逐字一致。 if joint, ok := joints[entryID]; ok { row.TeamName = joint.Name() row.Remark = appendNote(row.Remark, joint.shareNote()) } // 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名, // 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。 row.TeamName = withSquad(row.TeamName, squad) // results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。 // 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。 row.NoResult = !resultState.Valid row.Unpublished = !includeDrafts && resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft // 已发布但整组还没结算:成绩上屏了,名次与积分要等结算(见 Unsettled 的说明)。 // 这里的条件缺一不可,少任何一个都会误伤—— // published < required 且都非 0:这一组确实还没结算(与 /api/results/settlement 同判据); // **state 是 published**:草稿行不算(它没有对外,未发布口径已经把它划走了; // includeDrafts=true 时草稿行连值一起导出,也必须按草稿呈现,不能被这一分支抢走); // 有成绩值、状态是 ok:这才是「等名次」的那种行;犯规 / 弃权行本来就不占名次, // 把它们的「犯规」改写成「未结算」会把现场明确的判罚结果弄丢; // 名次与积分都空:实时结算(默认)下分批发布也会出现 published < required, // 但那一批已经结算过、名次与积分都在,不能标成未结算。 row.Unsettled = published > 0 && published < required && !row.NoResult && resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStatePublished && hasValue != 0 && rank <= 0 && row.Points == 0 && domain.ResultStatus(resultStat.String) == domain.ResultStatusOK 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 } joints, err := loadJointTeams(db) if err != nil { return TeamDetailWorkbook{}, err } rows, err := loadTeamDetailRows(db, teamID, joints) 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 } // teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。 // // score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找: // - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order); // - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id, // 就拿它 + 主体(单位 / 运动员)关联。 // // 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。 // channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。 const teamDetailRankExpr = `COALESCE(( SELECT cr.rank FROM channel_ranks cr JOIN event_score_channels ch ON ch.id = cr.channel_id WHERE cr.event_id = l.event_id AND cr.category_id = COALESCE(c.id, ca.id) AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id) OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' AND cr.channel_id = l.source_id AND ((cr.scope = 'team' AND cr.team_id = l.team_id) OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id)))) ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id LIMIT 1), COALESCE(r.rank, 0))` func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([]TeamDetailRow, error) { // 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空, // 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定; // 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。 // 参加了多个组别时回推不出来(同一 (小项, 单位) 有多条无法区分的聚合流水),宁可留空也不猜。 // 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。 rows, err := db.Query(` SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''), COALESCE(r.unit, ''), l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0), COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0), COALESCE(en.squad, '') 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 ( SELECT en2.event_id, MIN(en2.category_id) AS category_id FROM entries en2 WHERE en2.team_id = ? GROUP BY en2.event_id HAVING COUNT(DISTINCT en2.category_id) = 1 ) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' LEFT JOIN event_categories ca ON ca.id = ac.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(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`, teamID, teamID) if err != nil { return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err) } defer func() { _ = rows.Close() }() var out []TeamDetailRow // 每条流水背后的成绩身份((小项, 报名项),口径见 internal/revisions),用来补「备注」列的修订痕迹。 keys := make([]revisions.Key, 0, len(out)) for rows.Next() { var r TeamDetailRow var source string var athleteID, eventID, entryID int64 var resultText, unit string if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID, &r.squad); err != nil { return nil, fmt.Errorf("export: 扫描积分明细: %w", err) } // 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩 // 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。 r.ResultText = displayResult(resultText, unit) r.Source = sourceLabel(source) // 这条流水来自联队时说明份额:本单位拿到的是拆给它的那一份(积分列已经是拆后的值), // 备注里再把「分给了哪几个单位、各多少」一次写全,核对时不必回头查另一张表。 if joint, ok := joints[entryID]; ok { r.Remark = appendNote(r.Remark, joint.shareNote()) } r.individual = athleteID != 0 keys = append(keys, revisions.KeyFor(source, eventID, entryID)) out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历积分明细: %w", err) } attachRevisionRemarks(out, keys, loadRevisionTrails(db, keys)) return out, nil } // loadRevisionTrails 取这些成绩的修订痕迹;查不出来只留空,导出照旧出。 // // 导出件与屏幕上写的必须是同一句话(口径见 internal/revisions):明细在屏幕上挂着 // 「已修订 ×2」,xlsx 里一个字没有,事后拿表格对账的人就不知道该信哪份。 func loadRevisionTrails(db *sql.DB, keys []revisions.Key) map[revisions.Key]revisions.Trail { trails, err := revisions.Load(context.Background(), db, keys) if err != nil { return nil } return trails } // attachRevisionRemarks 把修订痕迹写进「备注」列:`已修订 ×2;最后一次 10:42 成绩录入笔误`。 // // 没改过(或追不出来)的行备注留空 —— 这个列本来只写「作废理由」,而作废的流水不会出现在这里, // 所以正常行本来就是空白,只有真被改过的成绩才会多出一句话。 func attachRevisionRemarks(rows []TeamDetailRow, keys []revisions.Key, trails map[revisions.Key]revisions.Trail) { for i, key := range keys { if i >= len(rows) { return } trail, ok := trails[key] if !ok || trail.Count == 0 { continue } text := fmt.Sprintf("已修订 ×%d", trail.Count) if clock := revisionClock(trail.At); clock != "" { text += ";最后一次 " + clock if trail.Reason != "" { text += " " + trail.Reason } } else if trail.Reason != "" { text += ";" + trail.Reason } if rows[i].Remark == "" { rows[i].Remark = text } else { rows[i].Remark += ";" + text } } } // revisionClock 把 result_revisions.created_at 转成 `HH:mm`(本地时区)。 // // 库里存的是 datetime('now') 的 UTC 但不带时区,按 UTC 解析再转本地 —— 与前端 // shared/format.ts 的 toDate 同一口径,否则导出件上的 10:42 会比大屏早 8 小时。 func revisionClock(at string) string { parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(at), time.UTC) if err != nil { return "" } return parsed.Local().Format("15:04") } 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 } if names := memberNamesOf(memberJSON); names != nil { return strings.Join(names, "、") } // 空值或坏 JSON:原样写出来(历史上 member_names 被手工写成别的样子也不能丢字)。 return strings.TrimSpace(memberJSON) } // memberNamesOf 解析 member_names(库里是 JSON 数组文本);空值、坏 JSON、空数组都返回 nil。 // // **每一项原样返回**(不去空白、不丢空串):它直接写进老数据的导出件,改动它等于改动老导出件 // 的每一个字。新的队员路径在合并文字名单时自己决定怎么去空白(见 participantLabel)。 func memberNamesOf(memberJSON string) []string { memberJSON = strings.TrimSpace(memberJSON) if memberJSON == "" { return nil } var raw []string if err := json.Unmarshal([]byte(memberJSON), &raw); err != nil || len(raw) == 0 { return nil } return raw }