## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
979 lines
37 KiB
Go
979 lines
37 KiB
Go
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
|
||
}
|