Files
Scoreboard/server/internal/export/load.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

979 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,保持名次列不空白。
//
// 关联用「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
}