feat(export): 新增「导入格式」报名名单导出(汇总 / 分小项),与导入模板逐列一致、可直接重导;版本 dev_0.5.14

主人反馈:导出页「报名名单」卡片导出的表格与导入模板对不上,没法拿回去重导。

核对属实(旧导出是给人看的报表):列序与 importColumns 不同、缺「种子 / 队员名单 / 队伍」
三列、姓名列被拼成「姓名(号码布)」、队名拼在单位名后面、状态列写「已退赛」而
normalizeImportStatus 不认 —— 整体错位 + 状态列报错,重导必炸。

- 新增 GET /api/export/entries-import.xlsx?format=flat|sheets&lang=zh|en&<四个筛选>
  · flat(缺省)= 所有行汇总在一张工作表;表名不含 @(导入侧见到带 @ 的表名就整份按
    分工作表模板解析,汇总表定不出「小项@组别」会被拒)
  · sheets = 按「小项@组别」分工作表,表名走 entrySheetName(与模板、导入解析三处同源,
    31 字符截断与撞名去重都跟着一起走)
  · 权限与筛选参数与 /api/export/entries.xlsx 完全一致(访客可读);空结果回 200 + 只有表头
  · 列序不写常量:按 importColumns 逐列取值,表头走 entryTemplateHeader(lang),
    加列/改序时测试当场红(TestEntryImportRecordCoversImportColumns)
- 取值口径(重导的关键):团体行的「姓名」留空、队员写「队员名单」列(中文逗号分隔;
  以库里 token 为底,再补上 entry_athletes 里后加的队员号码布,只增不减)、
  队伍独立成列、状态写导入器认的四个中文说法(正常参赛/退赛/取消资格/未出场)、
  联队照旧写 A+B、未分批留空、证件号/性别/年龄来自运动员档案(团体行空)
- 新增 internal/export/entries_import.go(读库层,与报表导出同源 SQL、取值口径刻意不同)
  与 internal/httpapi/entries_import_export.go(工作簿组装 + handler)
- 原有报表导出(/api/export/entries.xlsx 与 internal/export/entries.go)一个字没动;
  导出页新增两个按钮与中文/英文表头选择(只影响这两个导入格式)

测试:往返测试把导出件直接喂回报名导入解析器,flat 与 sheets 各断言四条(解析零错误 /
行数与 13 列逐项一致 / 团体行没被变成个人报名 / 两种格式都跑);另做变异验证 —— 状态改回
「已退赛」、给团体行填姓名,守卫当场变红。四套自检 ×2 全绿(finalcheck_0514.log:
前端 996 例 / Console 96 例 / 服务端 16 包,EXIT=0)。

无数据库迁移;已有成绩、名次与榜单不变。
文档:使用手册 §11.1(并订正「不要拿导出件重导」的旧话)、报名名单导入规范 新增第九节、
升级说明 新增 dev_0.5.14、常见问题 新增 Q104。
This commit is contained in:
AskaEth committed 2026-09-17 04:03:52 +08:00
1 parent 19e5630b39
commit ee19b92182
19 files changed
+2135 -34

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
package export
import (
"database/sql"
"fmt"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 本文件是「报名名单(导入格式)」的读库层:**逐列与报名导入模板一致**,导出来的表可以直接
// 拿去重新导入(往返口径由 httpapi/entries_import_export.go 组装工作簿,
// 端到端往返用例见 httpapi/entries_import_export_test.go)。
//
// 它与 entries.go 的报表导出读的是同一份数据,但**取值口径刻意不同**,两处不能互相套用:
// - 报表(LoadEntriesWorkbook)是给人看的:团体报名的姓名那一格拼上队员、状态写「已退赛」、
// 单位名列写全成员并带队名;
// - 本格式是给导入器看的:姓名那一格**留空**才算团体报名(填了姓名就会被当成个人报名)、
// 状态只能写导入器认的四个中文口径、列必须与 importColumns 逐列对得上。
//
// 两处一致的是「怎么读」:同一段 JOIN、同一套筛选、同一个 ORDER BY(未分批排最前)、
// 同样的联队回写(A+B)与队员关联。现场拿两份导出件对账时,行序与行数不会对不上。
// EntryImportRow 是「报名名单(导入格式)」的一行:13 个取值对应报名导入模板的 13 列
// (列序不在这里——由 httpapi 侧按 importColumns 的列序取值,见 entryImportRecord)。
//
// 除这 13 列外另带 (小项, 组别) 的身份字段:EventID / CategoryID 用于把行分到工作表上,
// EventName / CategoryName 用于拼「小项名称@组别名称」的工作表名(entrySheetName)。
type EntryImportRow struct {
EventID int64
EventCode string
EventName string
CategoryID int64
CategoryName string
TeamCode string
AthleteName string
IDCard string
Gender string
Age string
Bib string
Seed string
Heat string
Status string
MemberNames string
Squad string
}
// LoadEntriesImportRows 读报名名单,取值口径按「与导入模板逐列一致」来(见 EntryImportRow)。
//
// 筛选条件(EntriesFilter)与报表导出完全一样:某一项为零值即该维度不筛选;按单位筛选时
// **连同联队里的成员单位一起算**(与 LoadEntriesWorkbook 同一条:成员单位同样在这条报名上
// 拿分拿牌,按 B 导出时看不到它,B 就不知道自己派了谁上场)。
//
// opts 只为与其它 Load* 同形:本格式没有抬头(第一行就是列名),Meta 一概不用。
func LoadEntriesImportRows(db *sql.DB, filter EntriesFilter, opts Options) ([]EntryImportRow, error) {
if db == nil {
return nil, ErrNoDatabase
}
// 联队配置:单位编码回写成导入表的 A+B 写法,否则重导会把联队丢掉、只剩主单位那条份额。
joints, err := loadJointTeams(db)
if err != nil {
return nil, err
}
// 队员关联:整份名单读一次,逐行按 entry_id 取(与报表导出同一个 loader)。
members, err := loadEntryMembers(db)
if err != nil {
return nil, err
}
// 这段查询与 entries.go 的 LoadEntriesWorkbook **同源**:同样的 JOIN、同样的筛选、
// 同样的 ORDER BY。刻意没有抽成公共函数——两个导出件读的数据一样、取值口径不同,
// 共用一个函数会让人误以为取值也一样。**改这里要同步改那里**。
query := `
SELECT ev.id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
COALESCE(c.id, 0), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''), COALESCE(a.id_card, ''), COALESCE(a.gender, ''), a.age,
COALESCE(en.bib, ''), COALESCE(en.seed, ''), en.heat, en.status,
COALESCE(en.member_names, ''), en.athlete_id, en.id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN athletes a ON a.id = en.athlete_id`
var (
conds []string
args []any
)
for _, f := range []struct {
column string
value int64
}{
{"en.event_id", filter.EventID},
{"en.category_id", filter.CategoryID},
{"en.heat", filter.Heat},
} {
if f.value != 0 {
conds = append(conds, f.column+" = ?")
args = append(args, f.value)
}
}
// 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个),
// 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。
// 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。
// 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。
if filter.TeamID != 0 {
teamCond := "en.team_id = ?"
args = append(args, filter.TeamID)
ready, err := entrySharesReady(db)
if err != nil {
return nil, err
}
if ready {
teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s
WHERE s.entry_id = en.id AND s.team_id = ?)`
args = append(args, filter.TeamID)
}
conds = append(conds, "("+teamCond+")")
}
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
// 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。
// 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。
query += `
ORDER BY ev.sort_order, ev.code, c.sort_order, c.id,
en.heat IS NOT NULL, en.heat, 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 []EntryImportRow
for rows.Next() {
var (
row EntryImportRow
memberJSON string
heat sql.NullInt64
age sql.NullInt64
athleteID sql.NullInt64
athleteName string
entryID int64
)
if err := rows.Scan(&row.EventID, &row.EventCode, &row.EventName,
&row.CategoryID, &row.CategoryName,
&row.TeamCode, &row.Squad,
&athleteName, &row.IDCard, &row.Gender, &age,
&row.Bib, &row.Seed, &heat, &row.Status,
&memberJSON, &athleteID, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描报名名单(导入格式): %w", err)
}
// 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导回去才认得出这条联队。
if joint, ok := joints[entryID]; ok {
row.TeamCode = joint.Code()
}
if heat.Valid {
row.Heat = strconv.FormatInt(heat.Int64, 10)
}
if athleteID.Valid {
// 个人报名:姓名 / 证件号 / 性别 / 年龄都取这位运动员档案上的值。
// 团体报名(athlete_id 为空)这几列全留空——姓名一旦填了,导入侧就当成个人报名了。
row.AthleteName = athleteName
if age.Valid {
row.Age = strconv.FormatInt(age.Int64, 10)
}
} else {
// 团体报名:队员名单才是这一行的内容(导入侧按号码布认人)。
row.MemberNames = importMemberNames(memberJSON, members[entryID])
}
// 状态写**导入器认的**中文口径(正常参赛 / 退赛 / 取消资格 / 未出场)。
// 报表导出的「已退赛」导入器不认,照抄会让整份表每一条退赛行都报「status 无法识别」。
row.Status = entryImportStatusLabel(row.Status)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名名单(导入格式): %w", err)
}
return out, nil
}
// entryImportStatusLabel 把库里的 status 写成导入器认的中文口径。
//
// 取值必须是 normalizeImportStatus 认得的那几个(见 entry_status.go):正常参赛 / 退赛 /
// 取消资格 / 未出场。**不要**改成录入页徽标的「已退赛」——那是给人看的,导入器不认。
// 认不出的历史值原样写出(库里被 CHECK 约束守着,正常走不到这一支),
// 宁可让导入当场报「status 无法识别」,也不静默把一条状态改写成别的意思。
func entryImportStatusLabel(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
}
}
// importMemberNames 拼团体报名的「队员名单」一格:多个队员用中文逗号「,」分隔
// (导入侧 splitNames 认 , , / 三种分隔符,中文逗号在 Excel 里也最不容易被当成数字分隔符)。
//
// 取值以库里 member_names 存着的 token 为底,再把 entry_athletes 里已关联、但不在 token 里的
// 队员补上(有号码布写号码布,没有号码布的退回姓名)。两头都不能省:
// - 只导 token 会丢掉界面上后补的队员(他们只活在 entry_athletes 里,一个字都不在 token 中);
// - 只导号码布会把纯文字名单丢掉(人没录档案、临时借调的,token 里只有名字)。
//
// 号码布优先是因为导入侧就是按号码布认人的:写了号码布这一格重导回去仍关联得到人,
// 只写姓名则退化成文字(与导出件其它地方的 memberLabel 同一口径)。去重、顺序稳定:
// token 按原书写顺序在前,补上的队员按 entry_athletes 的顺序在后。
func importMemberNames(memberJSON string, members []entryMember) string {
tokens := memberNamesOf(memberJSON)
if tokens == nil {
// 空值、坏 JSON:整格当一段原样带过去。历史上 member_names 被手工写成过别的样子,
// 那些字在这个格式里同样不能丢。
if raw := strings.TrimSpace(memberJSON); raw != "" {
tokens = []string{raw}
}
}
out := make([]string, 0, len(tokens)+len(members))
seen := make(map[string]bool, len(tokens)+len(members))
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
return
}
seen[value] = true
out = append(out, value)
}
for _, token := range tokens {
add(token)
}
for _, member := range members {
if bib := strings.TrimSpace(member.Bib); bib != "" {
add(bib)
continue
}
add(member.Name)
}
return strings.Join(out, ",")
}
@@ -0,0 +1,247 @@
package export
import (
"database/sql"
"testing"
)
// seedEntriesImport 造一份覆盖导入格式全部取值口径的报名名单:
// 个人行(含同一人两个组别)/ 团体行(姓名必须留空)/ 联队(T1+T2)/ 多队(squad)/
// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。
//
// 与 seedEntries(报表导出的造数)刻意分开:那份的数据是按「给人看的报表」挑的
// (团体行的姓名要回落成员名单、状态要写「已退赛」),两边的期望值不一样。
func seedEntriesImport(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`,
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`,
`INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('张伟', '男', 1, '110101199001011234', 'A001', 35)`,
`INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('李强', '男', 2, '110101199202022345', 'B001', 33)`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A002')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵雷', '男', 1, 'A003')`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`,
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`,
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
// 1 个人行:号码布带前导零、有种子、已分批、正常参赛。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status)
VALUES (1, 1, 1, 1, '007', '3', 2, 'registered')`,
// 2 个人行:未分批(heat 为 NULL)、退赛。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (1, 1, 2, 2, 'B001', 'scratched')`,
// 3 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (1, 1, 1, '["A002","预备队员"]', '一队', 'dq')`,
// 4 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (1, 1, 1, '["临时队员"]', '二队', 'dns')`,
// 5 个人行:同一个人报第二个组别(号码布重复出现是合法的)。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (1, 2, 1, 1, '007', 'registered')`,
// 6 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (2, 3, 1, '["王磊","赵雷"]', 'registered')`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 2, 1, 1, 1)`,
`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 (4, 4, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 3, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 4, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// importKey 是测试里给每行起的键:小项编码 + 组别 + 队伍 + 号码布(四者合起来唯一)。
func importKey(r EntryImportRow) string {
return r.EventCode + "\x00" + r.CategoryName + "\x00" + r.Squad + "\x00" + r.Bib
}
func loadImportRowsByKey(t *testing.T, db *sql.DB, filter EntriesFilter) map[string]EntryImportRow {
t.Helper()
rows, err := LoadEntriesImportRows(db, filter, Options{})
if err != nil {
t.Fatalf("读取报名名单(导入格式): %v", err)
}
out := make(map[string]EntryImportRow, len(rows))
for _, r := range rows {
key := importKey(r)
if _, dup := out[key]; dup {
t.Fatalf("同一份名单里出现两行同样的键 %q:测试数据的键不够唯一", key)
}
out[key] = r
}
return out
}
func TestLoadEntriesImportRows(t *testing.T) {
db := openEventDB(t)
seedEntriesImport(t, db)
rows, err := LoadEntriesImportRows(db, EntriesFilter{}, Options{})
if err != nil {
t.Fatalf("读取报名名单(导入格式): %v", err)
}
if len(rows) != 6 {
t.Fatalf("行数 = %d,期望 6", len(rows))
}
got := loadImportRowsByKey(t, db, EntriesFilter{})
// 个人行:姓名 / 证件号 / 性别 / 年龄取运动员档案,号码布 / 种子 / 批次 / 状态取报名上的值。
personal := got["E1\x00男子\x00\x00007"]
if personal.AthleteName != "张伟" || personal.IDCard != "110101199001011234" ||
personal.Gender != "男" || personal.Age != "35" {
t.Errorf("个人行的人员字段 = %+v,期望张伟 / 110101199001011234 / 男 / 35", personal)
}
if personal.TeamCode != "T1" || personal.Seed != "3" || personal.Heat != "2" || personal.Status != "正常参赛" {
t.Errorf("个人行的报名字段 = %+v,期望 T1 / 种子 3 / 批次 2 / 正常参赛", personal)
}
if personal.MemberNames != "" {
t.Errorf("个人行的队员名单 = %q,期望留空(队员只对团体报名生效)", personal.MemberNames)
}
if personal.EventID != 1 || personal.CategoryID != 1 || personal.EventName != "100米跑" || personal.CategoryName != "男子" {
t.Errorf("个人行的 (小项, 组别) 身份字段 = %+v,组装工作表名要用它", personal)
}
// 未分批:heat 留空;四种状态的中文口径逐个钉住。
unbatched := got["E1\x00男子\x00\x00B001"]
if unbatched.Heat != "" {
t.Errorf("未分批行的批次 = %q,期望留空", unbatched.Heat)
}
if unbatched.Status != "退赛" {
t.Errorf("scratched 的状态 = %q,期望 退赛(导入器认的中文口径)", unbatched.Status)
}
if s := got["E1\x00男子\x00一队\x00"].Status; s != "取消资格" {
t.Errorf("dq 的状态 = %q,期望 取消资格", s)
}
if s := got["E1\x00男子\x00二队\x00"].Status; s != "未出场" {
t.Errorf("dns 的状态 = %q,期望 未出场", s)
}
for _, key := range []string{"E1\x00男子\x00\x00007", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"} {
if s := got[key].Status; s != "正常参赛" {
t.Errorf("%s 的状态 = %q,期望 正常参赛", key, s)
}
}
// 号码布带前导零:它是字符串,导出件里不能变成 7。
if got["E1\x00男子\x00\x00007"].Bib != "007" {
t.Errorf("号码布 = %q,期望 007", got["E1\x00男子\x00\x00007"].Bib)
}
// 团体行:姓名必须留空(填了姓名导入侧就当成个人报名了)。
teamOne := got["E1\x00男子\x00一队\x00"]
if teamOne.AthleteName != "" || teamOne.IDCard != "" || teamOne.Gender != "" || teamOne.Age != "" {
t.Errorf("团体行的人员字段 = %+v,期望全部留空(姓名一填就变成个人报名)", teamOne)
}
// 队员名单:文字 token 原样保留,已关联的队员不重复写(A002 与王磊是同一个人)。
if teamOne.MemberNames != "A002,预备队员" {
t.Errorf("团体行的队员名单 = %q,期望 A002,预备队员(token 去重在前)", teamOne.MemberNames)
}
// 界面上后补的队员(只活在 entry_athletes 里)必须按号码布补进队员名单,否则重导就丢了这个队员。
teamTwo := got["E1\x00男子\x00二队\x00"]
if teamTwo.MemberNames != "临时队员,A003" {
t.Errorf("团体行的队员名单 = %q,期望 临时队员,A003(后补的队员按号码布补上)", teamTwo.MemberNames)
}
// 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导才认得回这条联队。
joins := got["E2\x00男子\x00\x00"]
if joins.TeamCode != "T1+T2" {
t.Errorf("联队的单位编码 = %q,期望 T1+T2", joins.TeamCode)
}
if joins.MemberNames != "王磊,赵雷,A002,A003" {
t.Errorf("联队的队员名单 = %q,期望 王磊,赵雷,A002,A003(文字名单 + 补上的号码布)", joins.MemberNames)
}
}
// 筛选条件与报表导出同一套:按单位筛选要连联队里的成员单位一起算,
// 否则 B 按自己导出时看不到「A+B 那条联队里有我派的人」。
func TestLoadEntriesImportRowsFilters(t *testing.T) {
db := openEventDB(t)
seedEntriesImport(t, db)
cases := []struct {
name string
filter EntriesFilter
want []string
}{
{"全部", EntriesFilter{}, []string{
"E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00",
"E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}},
{"按小项", EntriesFilter{EventID: 1}, []string{
"E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00",
"E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007"}},
{"按组别", EntriesFilter{CategoryID: 3}, []string{"E2\x00男子\x00\x00"}},
{"按批次", EntriesFilter{Heat: 2}, []string{"E1\x00男子\x00\x00007"}},
// T2 是联队成员:那条联队报名必须出现(主单位是 T1)。
{"按单位(含联队成员单位)", EntriesFilter{TeamID: 2}, []string{"E1\x00男子\x00\x00B001", "E2\x00男子\x00\x00"}},
{"按单位(联队主单位)", EntriesFilter{TeamID: 1}, []string{
"E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00", "E1\x00男子\x00二队\x00",
"E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := loadImportRowsByKey(t, db, tc.filter)
if len(got) != len(tc.want) {
t.Fatalf("筛选后行数 = %d,期望 %d(%v)", len(got), len(tc.want), keysOf(got))
}
for _, key := range tc.want {
if _, ok := got[key]; !ok {
t.Errorf("筛选结果里缺少 %q(实际 %v)", key, keysOf(got))
}
}
})
}
// 没有数据不是错误:批次 99 没人报名,返回空表(导出件仍是一张带表头的表)。
rows, err := LoadEntriesImportRows(db, EntriesFilter{Heat: 99}, Options{})
if err != nil {
t.Fatalf("读取空结果: %v", err)
}
if len(rows) != 0 {
t.Errorf("批次 99 应为空,得到 %d 行", len(rows))
}
// 没有库时必须报错,不能返回一份空名单(那会被当成「这次没人报名」)。
if _, err := LoadEntriesImportRows(nil, EntriesFilter{}, Options{}); err != ErrNoDatabase {
t.Errorf("空库连接的错误 = %v,期望 ErrNoDatabase", err)
}
}
func keysOf(rows map[string]EntryImportRow) []string {
out := make([]string, 0, len(rows))
for key := range rows {
out = append(out, key)
}
return out
}
// 状态列必须写**导入器认的**中文口径:报表那份写的「已退赛」导入器不认,
// 照抄过去会让整份表每一条退赛行都报「status 无法识别」。
func TestEntryImportStatusLabel(t *testing.T) {
cases := map[string]string{
"": "正常参赛",
"registered": "正常参赛",
"scratched": "退赛",
"dq": "取消资格",
"dns": "未出场",
}
for status, want := range cases {
if got := entryImportStatusLabel(status); got != want {
t.Errorf("状态 %q 的标签 = %q,期望 %q", status, got, want)
}
}
for _, label := range cases {
if label == "已退赛" {
t.Errorf("「已退赛」是报表口径,导入器认的是「退赛」")
}
}
// 认不出的历史值原样写出:宁可让导入当场报「status 无法识别」,也不静默改成别的意思。
if got := entryImportStatusLabel("unknown"); got != "unknown" {
t.Errorf("认不出的状态 = %q,期望原样写出", got)
}
}