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` 两张)。
This commit is contained in:
1 parent
7a3e4ad365
commit
99c91f99f7
57 files changed
+4903
-94
No files matched your search
@@ -128,6 +128,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
// 队员关联(devdocs/20):整份名单读一次,逐行按 entry_id 取。
|
||||
members, err := loadEntryMembers(db)
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
|
||||
@@ -206,7 +211,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
|
||||
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
|
||||
}
|
||||
row.Name = participantName(athleteName, memberJSON)
|
||||
row.Name = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
if heat.Valid {
|
||||
row.Heat = strconv.FormatInt(heat.Int64, 10)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是「团体报名的队员」(devdocs/20)在导出侧的读法:一条团体报名可以关联多个运动员
|
||||
// (`entry_athletes`),导出件上要看得出队里坐着谁。
|
||||
//
|
||||
// 写法是 `姓名(号码布)`:号码布是运动员的唯一标识(迁移 0014、devdocs/20 §二),
|
||||
// 光写名字遇到重名就认不出是谁。多个队员用顿号连起来——与 member_names 原有的写法同形,
|
||||
// 拿导出件对账的人不必为两种名单学两套读法。
|
||||
//
|
||||
// member_names **不因为挂了队员而消失**:它是显示用的文字名单,里面没被队员认走的那些名字
|
||||
// (人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。同一个口径也用在
|
||||
// 大屏侧(web/src/display/members.ts),两处必须一致,否则同一场比赛的同一支队伍
|
||||
// 在导出件与屏幕上会写成两份名单。
|
||||
//
|
||||
// 队员**不改每一行的高度与条数**:一条报名仍然只占一行(护栏见 entry_members_rows_test.go),
|
||||
// 队员只让这一格更长。
|
||||
|
||||
// entryMember 是报名里一名队员的展示字段。身份是 athlete_id(引擎按它发个人分与个人奖牌),
|
||||
// 这里只读展示需要的两列:姓名与号码布。
|
||||
type entryMember struct {
|
||||
Name string
|
||||
Bib string
|
||||
}
|
||||
|
||||
// entryMembersReady 判断队员表在不在。
|
||||
//
|
||||
// 表不在(库还没跑到 0026)= 库里没有任何队员关联 = 只报单位,与升级前一致,
|
||||
// 所以按「没有队员」处理是**精确**的,不是容错降级(与 entrySharesReady 同一判据)。
|
||||
func entryMembersReady(db *sql.DB) (bool, error) {
|
||||
var n int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
|
||||
return false, fmt.Errorf("export: 查询报名队员表: %w", err)
|
||||
}
|
||||
return n != 0, nil
|
||||
}
|
||||
|
||||
// loadEntryMembers 读全库的队员关联,按报名 id 归组。
|
||||
//
|
||||
// 顺序按 sort_order(写侧按请求给的顺序编号,老数据按文字名单的书写顺序回填):
|
||||
// 展示顺序稳定,导出件不会因为主键顺序而变。各导出件都读一次给全部工作表复用——
|
||||
// 逐张表去查是白跑几十遍(与 loadJointTeams 同一个取舍)。
|
||||
func loadEntryMembers(db *sql.DB) (map[int64][]entryMember, error) {
|
||||
ready, err := entryMembersReady(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ready {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT ea.entry_id, COALESCE(a.name, ''), COALESCE(a.bib, '')
|
||||
FROM entry_athletes ea
|
||||
JOIN athletes a ON a.id = ea.athlete_id
|
||||
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取报名队员: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]entryMember)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
m entryMember
|
||||
)
|
||||
if err := rows.Scan(&entryID, &m.Name, &m.Bib); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描报名队员: %w", err)
|
||||
}
|
||||
out[entryID] = append(out[entryID], m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历报名队员: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// memberLabel 把一名队员写成导出件上的字:`张伟(A001)`。
|
||||
//
|
||||
// 姓名或号码布缺一个时只写有的那个(不留「()」这种空壳);两个都空的人在名单里一个字都不写。
|
||||
func memberLabel(m entryMember) string {
|
||||
name := strings.TrimSpace(m.Name)
|
||||
bib := strings.TrimSpace(m.Bib)
|
||||
switch {
|
||||
case name != "" && bib != "":
|
||||
return name + "(" + bib + ")"
|
||||
case name != "":
|
||||
return name
|
||||
default:
|
||||
return bib
|
||||
}
|
||||
}
|
||||
|
||||
// coveredByMember 判断文字名单里的一个名字是不是已经由某名队员写过了。
|
||||
//
|
||||
// 判据与迁移 0026 的回填同源:文字名单里写的可能是**姓名**,也可能是**号码布**
|
||||
// (导入规范允许「队员名单」列填号码布)。认走之后不再重复写一遍,否则一个队员在导出件上
|
||||
// 会出现两次(「王五(A001)、王五」这种)。
|
||||
func coveredByMember(name string, members []entryMember) bool {
|
||||
for _, m := range members {
|
||||
if name == strings.TrimSpace(m.Name) || name == strings.TrimSpace(m.Bib) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// participantLabel 是报名名单 / 分项成绩表 / 全部成绩单页 / 单位奖牌明细共用的
|
||||
// 「姓名」「运动员 / 队」列取值。
|
||||
//
|
||||
// 没有队员关联时**逐字**等于 participantName:个人写姓名、团体写文字名单,一个字都不多
|
||||
// ——老数据(含迁移按文字名单回填不上人的那些)的导出件不许有任何变化。
|
||||
func participantLabel(athleteName, memberJSON string, members []entryMember) string {
|
||||
if len(members) == 0 {
|
||||
return participantName(athleteName, memberJSON)
|
||||
}
|
||||
out := make([]string, 0, len(members)+1)
|
||||
for _, m := range members {
|
||||
if label := memberLabel(m); label != "" {
|
||||
out = append(out, label)
|
||||
}
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range memberNamesOf(memberJSON) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] || coveredByMember(name, members) {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// 队员既没姓名也没号码布、文字名单也是空的:退回原来的取值,不把这一格写空。
|
||||
return participantName(athleteName, memberJSON)
|
||||
}
|
||||
return strings.Join(out, "、")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是「没有队员关联的报名,导出件逐行一致」这条硬门槛的对拍工具(devdocs/20 的导出侧)。
|
||||
//
|
||||
// 做法与 squad_dump_test.go 完全一致:把每一份导出件的每一张工作表、每一个单元格 dump 成文本
|
||||
// (`工作簿|工作表|行|列|值`),先落盘到 EXPORT_MEMBERS_DUMP 指定的路径(不设时写 t.TempDir(),
|
||||
// 平时只当一次「全部导出件都能加载」的冒烟)。把**同一个文件**放到改动前的代码上再跑一次
|
||||
// (对拍目录里的那份树只补这个用例、不补源码改动),两份 dump `diff` 必须为空。
|
||||
//
|
||||
// 为什么这份数据里没有 entry_athletes 行:老库升级后绝大多数报名都没有队员关联(只报单位的
|
||||
// 团体报名、个人报名、只登记文字名单的报名),本单不许让它们的导出件发生任何变化——包括
|
||||
// 因为「多查了一张表」「多拼了一个空串」而变的那一种。挂上队员之后的变化由下面的灵敏度自检盯着。
|
||||
|
||||
// TestMemberlessExportDumpIsUnchanged 见文件头。数据集复用 seedSquadDumpEvent:它覆盖了
|
||||
// 个人小项(含团体行回落成员名单)、纯团体报名、联队份额与队名,且**一条 entry_athletes 都没有**。
|
||||
func TestMemberlessExportDumpIsUnchanged(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedSquadDumpEvent(t, db)
|
||||
|
||||
opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}}
|
||||
memberless := dumpAllWorkbooks(t, db, opts)
|
||||
|
||||
// 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。
|
||||
path := os.Getenv("EXPORT_MEMBERS_DUMP")
|
||||
if path == "" {
|
||||
path = filepath.Join(t.TempDir(), "members-empty-dump.txt")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(memberless), 0o644); err != nil {
|
||||
t.Fatalf("写出 dump: %v", err)
|
||||
}
|
||||
t.Logf("无队员 dump:%s(%d 个单元格)", path, strings.Count(memberless, "\n"))
|
||||
|
||||
// 灵敏度自检:给双打那条报名挂上一名队员(另一名仍然是文字名单里的人),重新 dump,两次必须不同。
|
||||
// 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到队员列——那就白对拍了。
|
||||
// 在对拍用的旧代码上这条自检本来就会红(旧代码根本不读 entry_athletes),正说明两份 dump 的
|
||||
// 差异只可能来自这次改动。
|
||||
stmts := []string{
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A001')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
|
||||
SELECT e.id, a.id, 0 FROM entries e, athletes a
|
||||
WHERE e.event_id = 2 AND e.member_names LIKE '%王磊%' AND a.name = '王磊'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
withMembers := dumpAllWorkbooks(t, db, opts)
|
||||
if withMembers == memberless {
|
||||
t.Fatal("给报名挂上队员后 dump 没变:这份对拍看不见队员列,对拍结论无效")
|
||||
}
|
||||
// 关联上的写「姓名(号码布)」,没关联上的文字名字接在后面(人没录档案的临时队员照旧在名单里)。
|
||||
if !strings.Contains(withMembers, "王磊(A001)、赵雷") {
|
||||
t.Fatal("挂队员后的 dump 里找不到「王磊(A001)、赵雷」:队员没有按「姓名(号码布)」写进导出件")
|
||||
}
|
||||
if strings.Contains(memberless, "(A001)") {
|
||||
t.Fatal("无队员的 dump 里不该出现号码布写法")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「团体报名挂上队员之后,成绩表里这条报名仍然只占一行」(devdocs/20 的导出侧边界)。
|
||||
//
|
||||
// 原因:一条报名在同一通道下会有 1 + N 行 channel_ranks(报名项自己那份 + 每个队员一份个人副本),
|
||||
// 成绩表按 (通道, 报名, 组别) JOIN 名次时会把这条报名印 N+1 遍——现场看到的就是「双打报了三次」。
|
||||
// 名次取「报名项那一份」(最小 id),值与升级前一致。
|
||||
func TestEventRowsKeepOneRowPerEntryWithMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntryWithMembers(t, db)
|
||||
|
||||
rows, err := loadEventRows(db, 1, 0, true, map[int64]jointTeam{})
|
||||
if err != nil {
|
||||
t.Fatalf("读成绩行: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("成绩表应只有一条报名那一行,得到 %d 行:%+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Rank != "1" {
|
||||
t.Errorf("名次应取报名项那一份(第 1 名),得到 %q", rows[0].Rank)
|
||||
}
|
||||
// 运动员列写队员的「姓名(号码布)」:号码布是唯一标识,导出侧要认得出是谁上了场
|
||||
// (写法与理由见 entry_members.go)。这条报名仍然只占这一行——挂队员不许把行展开。
|
||||
if rows[0].Participant != "王五(A001)、赵六(A002)" {
|
||||
t.Errorf("队员名单应写成「姓名(号码布)」,得到 %q", rows[0].Participant)
|
||||
}
|
||||
}
|
||||
|
||||
// seedEntryWithMembers 造一条「双打」团体报名:队员关联两条,channel_ranks 里三条名次
|
||||
// (报名项 + 两个队员的个人副本),与发布后的真实落库形状一致。
|
||||
func seedEntryWithMembers(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '羽毛球双打', 'team', 'auto', 'count', 'desc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names)
|
||||
VALUES (1, 1, 1, '["王五","赵六"]')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 0)`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 2, 1)`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 1, 1, '21', 21, '分', 'published')`,
|
||||
// 报名项那一份(先落库,id 最小)+ 两个队员的个人副本。
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:1', 1, 1, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:2', 1, 1, 2, 1, 10)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住导出侧的「团体报名的队员」(devdocs/20):报名名单的「姓名」列、分项成绩表 /
|
||||
// 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列,都要看得出这条报名里坐着谁。
|
||||
//
|
||||
// 写法是 `姓名(号码布)`(断言里的字面量就是导出件上那一格的字):号码布是运动员的唯一标识
|
||||
// (迁移 0014、devdocs/20 §二),光写名字遇到重名认不出人。多个队员用顿号连起来——与
|
||||
// member_names 原有的写法同形,读者不必为两种名单学两套读法。
|
||||
//
|
||||
// member_names **不因为挂了队员而消失**:它是文字名单,里面没被队员关联认走的那些名字
|
||||
// (人没录档案、临时借调的)照旧接在后面。两种情况都要能一眼看出谁上了场。
|
||||
//
|
||||
// 一条报名仍然只占一行(护栏在 entry_members_rows_test.go):队员只让这一行更长,
|
||||
// 不把报名展开成 N+1 行。
|
||||
|
||||
// seedDoublesMembers 造一个「双打」小项,三种报名形态各一条:
|
||||
// - 挂了队员的团体报名(文字名单里还多一个没录档案的名字);
|
||||
// - 只有文字名单的团体报名(升级前的老形状,用来钉住「没有队员关联时逐字不变」);
|
||||
// - seedEvent 的个人小项(100 米跑),个人报名的姓名一个字都不该变。
|
||||
//
|
||||
// channel_ranks 与流水的形状与发布后的真实落库一致:一条报名 1 + N 行名次(报名项自己那份
|
||||
// 排在 id 最小的位置 + 每个队员一份个人副本,subject_key 是 'e:<报名项>:a:<运动员>'),
|
||||
// 队员那两份流水恒 count_to_team=0(只进个人榜,不进单位总分)。
|
||||
func seedDoublesMembers(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
seedEvent(t, db) // T1/T2、张三 / 李四、100 米跑(两条已发布成绩与流水)
|
||||
stmts := []string{
|
||||
`INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
|
||||
// 组别 id 是 2(seedEvent 的 100 米跑已经占了 id 1):报名与名次都必须挂在它名下,
|
||||
// 否则导出按 (小项, 组别) 取成绩时一条都取不到。
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
|
||||
// 双打配的是个人通道(队长口径:上个人积分榜);通道的 count_to_team 由现场在通道里配,
|
||||
// 这里留默认 1——单位维度只算这条报名一次,与通道开关是两件事(见 domain.memberLedger)。
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (2, 2, 1, '["王五","赵六","临时借调的老周"]', 'registered')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 4, 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (2, 2, 2, '["孙七","周八"]', 'registered')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (2, 3, 1, '21', 21, 'pts', 'published')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (2, 4, 2, '18', 18, 'pts', 'published')`,
|
||||
// 报名项那一份先落(id 最小 = 单位维度与成绩表读到的那一份),后面是队员的个人副本。
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3', 3, 3, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:3', 3, 3, 3, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:4', 3, 3, 4, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:4', 4, 4, 2, 2, 8)`,
|
||||
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
|
||||
points, count_to_team)
|
||||
VALUES (1, 2, 3, 3, 'result', 3, 10, 1)`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
|
||||
source_id, points, count_to_team)
|
||||
VALUES (1, 3, 2, 3, 3, 'result', 3, 10, 0)`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
|
||||
source_id, points, count_to_team)
|
||||
VALUES (1, 4, 2, 3, 3, 'result', 3, 10, 0)`,
|
||||
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
|
||||
points, count_to_team)
|
||||
VALUES (2, 2, 4, 4, 'result', 4, 8, 1)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报名名单:「姓名」列写队员的「姓名(号码布)」,文字名单里没被认走的那个名字接在后面;
|
||||
// 没有队员关联的报名(老形状)一个字都不变。
|
||||
func TestEntriesWorkbookShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{EventID: 2}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读报名名单: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != 2 {
|
||||
t.Fatalf("双打应有 2 条报名,得到 %d 条:%+v", len(wb.Rows), wb.Rows)
|
||||
}
|
||||
if got := wb.Rows[0].Name; got != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("挂队员那行的姓名 = %q,期望 王五(A001)、赵六(A002)、临时借调的老周", got)
|
||||
}
|
||||
if got := wb.Rows[1].Name; got != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行的姓名 = %q,期望 孙七、周八(与升级前逐字一致)", got)
|
||||
}
|
||||
|
||||
// 导出件本身(落盘那份与响应体同源)也要是这一格的字。
|
||||
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
|
||||
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
|
||||
t.Fatalf("导出报名名单: %v", err)
|
||||
}
|
||||
f := openXLSX(t, dst)
|
||||
defer func() { _ = f.Close() }()
|
||||
rows, err := f.GetRows(entriesSheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单工作表: %v", err)
|
||||
}
|
||||
// 4 行版式 + 4 条报名(个人两条 + 双打两条);姓名是第 6 列。
|
||||
names := make([]string, 0, len(rows))
|
||||
for i := 4; i < len(rows); i++ {
|
||||
names = append(names, cell(rows, i, 5))
|
||||
}
|
||||
want := []string{"张三", "李四", "王五(A001)、赵六(A002)、临时借调的老周", "孙七、周八"}
|
||||
if !equalStrings(names, want) {
|
||||
t.Errorf("报名名单姓名列 = %v,期望 %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 分项成绩表与全部成绩单页的「运动员/队」列:队员带号码布写全,且**一条报名只占一行**。
|
||||
func TestEventWorkbookShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 2, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读分项成绩: %v", err)
|
||||
}
|
||||
if len(wb.Sheets) != 1 {
|
||||
t.Fatalf("双打只有 1 个组别,得到 %d 张表", len(wb.Sheets))
|
||||
}
|
||||
rows := wb.Sheets[0].Rows
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("成绩表应只有 2 条报名那两行,得到 %d 行:%+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Participant != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("第 1 名运动员/队 = %q,期望队员带号码布写全", rows[0].Participant)
|
||||
}
|
||||
if rows[0].Rank != "1" {
|
||||
t.Errorf("第 1 名名次 = %q,期望 1(取报名项那一份名次)", rows[0].Rank)
|
||||
}
|
||||
if rows[1].Participant != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", rows[1].Participant)
|
||||
}
|
||||
|
||||
flat, err := LoadFlatWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读全部成绩单页: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, sh := range flat.Sheets {
|
||||
for _, r := range sh.Rows {
|
||||
if r.Participant == "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("全部成绩单页里找不到队员带号码布的写法")
|
||||
}
|
||||
}
|
||||
|
||||
// 单位奖牌明细的「运动员/队」列:这块奖牌是哪支队拿的,队员照样写全。
|
||||
func TestMedalDetailShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读单位奖牌明细: %v", err)
|
||||
}
|
||||
var participants []string
|
||||
for _, sheet := range wb.Sheets {
|
||||
for _, r := range sheet.Rows {
|
||||
if r.EventName == "男子双打" {
|
||||
participants = append(participants, r.Participant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(participants) != 2 {
|
||||
t.Fatalf("两块奖牌各一行,得到 %d 行:%v", len(participants), participants)
|
||||
}
|
||||
if participants[0] != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("双打金牌那行 = %q,期望队员带号码布写全", participants[0])
|
||||
}
|
||||
if participants[1] != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", participants[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 个人得分明细:一条双打报名在个人维度上是**每人一行、每人一整份**(不是一行两个人、
|
||||
// 也不是一行半份)。这是主人定的口径(devdocs/20 §二):每个队员各拿一份完整的个人分,
|
||||
// 于是两个人都能上个人积分榜——导出件上就该是这样两行。
|
||||
//
|
||||
// 名单里没关联上人的名字(临时借调的)**不产生个人行**:他上不了个人榜,导出件也不该凭空给他记分。
|
||||
func TestAthleteDetailGivesEachMemberAFullCopy(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadAthleteDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读个人得分明细: %v", err)
|
||||
}
|
||||
byName := make(map[string][]AthleteDetailRow)
|
||||
for _, sheet := range wb.Sheets {
|
||||
for _, r := range sheet.Rows {
|
||||
byName[r.AthleteName] = append(byName[r.AthleteName], r)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"王五", "赵六"} {
|
||||
rows := byName[name]
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("%s 应有 1 行个人明细,得到 %d 行:%+v", name, len(rows), rows)
|
||||
}
|
||||
if rows[0].EventName != "男子双打" || rows[0].Points != 10 || rows[0].Rank != 1 {
|
||||
t.Errorf("%s 的个人明细 = %+v,期望 男子双打 / 第 1 名 / 10 分(一整份,不拆)", name, rows[0])
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"孙七", "周八", "临时借调的老周"} {
|
||||
if rows := byName[name]; len(rows) != 0 {
|
||||
t.Errorf("%s 没有个人关联 / 没有个人流水,不该出现在个人明细里,得到 %+v", name, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单位积分明细:队员的个人副本恒 count_to_team=0,所以这条报名在单位维度上**只算一次**——
|
||||
// 双打不会让单位的总分变成两份,单位明细里也只有报名项那一行。
|
||||
func TestTeamDetailCountsEntryOnceWithMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读单位积分明细: %v", err)
|
||||
}
|
||||
var doubles []TeamDetailRow
|
||||
for _, r := range wb.Rows {
|
||||
if r.EventName == "男子双打" {
|
||||
doubles = append(doubles, r)
|
||||
}
|
||||
}
|
||||
if len(doubles) != 1 {
|
||||
t.Fatalf("双打在单位明细里应只有 1 行(队员的副本不进单位总分),得到 %d 行:%+v", len(doubles), doubles)
|
||||
}
|
||||
if doubles[0].Points != 10 {
|
||||
t.Errorf("单位实得分 = %v,期望 10(只算报名项那一份,不是 30)", doubles[0].Points)
|
||||
}
|
||||
if doubles[0].AthleteName != "" {
|
||||
t.Errorf("单位明细的运动员列 = %q,期望留空(这一分记在单位头上)", doubles[0].AthleteName)
|
||||
}
|
||||
}
|
||||
@@ -431,18 +431,22 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
|
||||
}
|
||||
|
||||
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 := loadEventRows(db, ev.id, 0, opts.IncludeDrafts, joints)
|
||||
rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members)
|
||||
if err != nil {
|
||||
return EventsWorkbook{}, err
|
||||
}
|
||||
@@ -450,7 +454,7 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
|
||||
continue
|
||||
}
|
||||
for _, cat := range cats {
|
||||
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts, joints)
|
||||
rows, err := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members)
|
||||
if err != nil {
|
||||
return EventsWorkbook{}, err
|
||||
}
|
||||
@@ -529,12 +533,30 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
||||
// 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)。
|
||||
@@ -559,8 +581,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
|
||||
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.channel_id = mc.id AND cr.entry_id = en.id
|
||||
AND cr.category_id = en.category_id
|
||||
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'
|
||||
@@ -608,7 +631,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
|
||||
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
|
||||
}
|
||||
row.Participant = participantName(athleteName, memberJSON)
|
||||
row.Participant = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
row.Rank = rankLabel(rank, isTie != 0)
|
||||
row.Status = statusLabel(resultStat, resultState, entryStat)
|
||||
row.Remark = joinRemark(entryRemark, resultNote)
|
||||
@@ -931,13 +954,25 @@ func participantName(athleteName, memberJSON string) string {
|
||||
if strings.TrimSpace(athleteName) != "" {
|
||||
return athleteName
|
||||
}
|
||||
memberJSON = strings.TrimSpace(memberJSON)
|
||||
if memberJSON == "" {
|
||||
return ""
|
||||
}
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 {
|
||||
if names := memberNamesOf(memberJSON); names != nil {
|
||||
return strings.Join(names, "、")
|
||||
}
|
||||
return memberJSON
|
||||
// 空值或坏 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
|
||||
}
|
||||
@@ -200,7 +200,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
}
|
||||
excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false")
|
||||
hits := medalHits(ranked, excludeTeam)
|
||||
ctx, err := loadMedalContext(db)
|
||||
// 队员关联读一次给全部奖牌行复用(与联队份额同一次读取的理由)。
|
||||
members, err := loadEntryMembers(db)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
ctx, err := loadMedalContext(db, members)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
@@ -340,10 +345,12 @@ type medalContext struct {
|
||||
categoryID int64
|
||||
}
|
||||
|
||||
func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
// loadMedalContext 取每条成绩的展示字段;members 是报名 id → 队员(见 loadEntryMembers),
|
||||
// 用来把团体报名的队员写进「运动员 / 队」列。
|
||||
func loadMedalContext(db *sql.DB, members map[int64][]entryMember) (map[int64]medalContext, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''), en.id,
|
||||
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0),
|
||||
e.id, COALESCE(c.id, 0)
|
||||
FROM results r
|
||||
@@ -363,14 +370,15 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
resultID int64
|
||||
athleteName string
|
||||
memberJSON string
|
||||
entryID int64
|
||||
c medalContext
|
||||
)
|
||||
if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category,
|
||||
&athleteName, &memberJSON, &c.sportSort, &c.eventSort, &c.catSort,
|
||||
&athleteName, &memberJSON, &entryID, &c.sportSort, &c.eventSort, &c.catSort,
|
||||
&c.eventID, &c.categoryID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err)
|
||||
}
|
||||
c.participant = participantName(athleteName, memberJSON)
|
||||
c.participant = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
out[resultID] = c
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
Reference in new issue
Block a user