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:
1 parent
19e5630b39
commit
ee19b92182
19 files changed
+2135
-34
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user