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)
}
}
@@ -0,0 +1,315 @@
package httpapi
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// 报名名单(导入格式)导出:**逐列与报名导入模板一致**,导出来的文件可以直接拿去重新导入。
//
// 与报表导出(GET /api/export/entries.xlsx,internal/export/entries.go)是两份东西,
// 各自的口径不能互相套用:
// - 报表是给人看的:姓名列拼上队员、状态写「已退赛」、表头是中文名,导入器不认它;
// - 这份按 importColumns 逐列写(表头就是导入模板的列名),团体报名的姓名列留空、
// 状态写导入器认的中文口径、联队写 A+B —— 重导回去一行都不会错位。
//
// 两种布局:
// - flat:所有行汇总在一张工作表里,**表名不带 @**(导入侧只要见到一张表名带 @ 就整份
// 按分工作表模板解析,这张汇总表定不出 (小项, 组别) 就会被拒绝);
// - sheets:按「小项@组别」分工作表,表名由 entrySheetName 算,与导入模板、导入解析三处同源。
// **不能多出任何一张工作表**(一张说明表就会让整份文件按分工作表口径解析对不上号)。
// 两种布局的 format 取值。
const (
entryImportFormatFlat = "flat"
entryImportFormatSheets = "sheets"
)
// entryImportFlatSheet 是 flat 布局唯一那张工作表的名字(与报表导出的表名一致)。
// 它是**普通表名**:绝不能让 entrySheetName 的结果在这里出现,否则这份文件会被当成
// 分工作表模板解析,而它定不出小项与组别 → 整份被拒绝。
const entryImportFlatSheet = "报名名单"
// exportEntriesImportXLSX 导出「与报名导入模板逐列一致」的报名名单(xlsx)。
//
// 筛选参数(eventId / categoryId / teamId / heat)与报表导出完全一样,都可省;
// format 缺省 flat;lang 只有 "en" 走英文规范列名,其余一律中文(与模板下载的两个按钮同一口径)。
func (s *server) exportEntriesImportXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
if format == "" {
format = entryImportFormatFlat
}
if format != entryImportFormatFlat && format != entryImportFormatSheets {
return nil, badRequest("format 只能是 flat(一张表汇总)或 sheets(按小项@组别分工作表)", format)
}
language := "zh"
if strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lang"))) == "en" {
language = "en"
}
// 与报表导出(exportEntriesXLSX)同一套筛选参数:留空 = 不筛,填了必须是正整数。
filter := export.EntriesFilter{}
for _, f := range []struct {
key string
dst *int64
what string
}{
{"eventId", &filter.EventID, "小项"},
{"categoryId", &filter.CategoryID, "组别"},
{"teamId", &filter.TeamID, "单位"},
{"heat", &filter.Heat, "批次"},
} {
id, err := filterIDQuery(r, f.key, f.what)
if err != nil {
return nil, err
}
*f.dst = id
}
rows, err := export.LoadEntriesImportRows(s.ev, filter, export.Options{})
if err != nil {
return nil, internal(err)
}
sheets, err := s.entryImportSheets(r.Context(), format, rows)
if err != nil {
return nil, err
}
filename, ascii := entryImportFilename(format)
return s.streamDownload(w, ascii, filename, xlsxMIME, func(out io.Writer) error {
return writeEntriesImportXLSX(out, language, sheets)
})
}
// entryImportSheets 把导出的行分到工作表上。
//
// flat(或一条报名都没有)永远只有一张「报名名单」表:空名单也要给一张带表头的表,
// 拿到手的人一眼看得出是「这次没有数据」,而不是遇到一个莫名其妙的错误。
func (s *server) entryImportSheets(ctx context.Context, format string, rows []export.EntryImportRow) ([]entryImportSheet, error) {
if format != entryImportFormatSheets || len(rows) == 0 {
return []entryImportSheet{{Name: entryImportFlatSheet, Rows: rows}}, nil
}
// 工作表名必须与**导入侧**算出来的完全一致,所以这里重算同一份计划(同一个
// loadEntrySheetScopes + newEntrySheetPlan,下载模板、导入解析、本导出三处同源)。
// 自己拼「小项@组别」在撞名去重与 31 字符截断上会与导入侧分叉,分叉的后果是整份表导不回去。
scopes, err := s.loadEntrySheetScopes(ctx)
if err != nil {
return nil, err
}
plan := newEntrySheetPlan(scopes)
nameOf := make(map[[2]int64]string, len(plan.sheets))
for _, planned := range plan.sheets {
nameOf[[2]int64{planned.Scope.EventID, planned.Scope.CategoryID}] = planned.Name
}
grouped := make(map[[2]int64][]export.EntryImportRow, len(plan.sheets))
unnameable := make([]string, 0, 2)
seenUnnameable := make(map[string]bool, 2)
for _, row := range rows {
key := [2]int64{row.EventID, row.CategoryID}
if _, ok := nameOf[key]; !ok {
// 只有名字里带 @ 的小项 / 组别做不出工作表名(entrySheetName 那里就被排除了)。
// 静默丢掉这些行等于现场少了几十条报名而毫不知情,所以整次拒绝并点名。
label := row.EventName + entrySheetSeparator + row.CategoryName
if !seenUnnameable[label] {
seenUnnameable[label] = true
unnameable = append(unnameable, label)
}
continue
}
grouped[key] = append(grouped[key], row)
}
if len(unnameable) > 0 {
if len(plan.excluded) > 0 {
return nil, conflict(entrySheetExcludedMessage(plan.excluded))
}
return nil, conflict("这几个小项 / 组别做不出「小项名称" + entrySheetSeparator + "组别名称」的工作表名:" +
strings.Join(unnameable, "、") + "。请到「基础数据 → 大项 / 小项」「组别」检查它们的名字," +
"或改用 format=flat(一张表汇总)导出")
}
// 工作表顺序照计划来(与模板的先后一致),表内行序照导出的 ORDER BY:
// 同一份数据每次导出的样子都一样,现场两张表对着看时不用重新找行。
out := make([]entryImportSheet, 0, len(plan.sheets))
for _, planned := range plan.sheets {
key := [2]int64{planned.Scope.EventID, planned.Scope.CategoryID}
if len(grouped[key]) == 0 {
// 这个小项组别这次没有人报名:不出空表。空表是模板用来教人填的,数据导出件里
// 几十张空表只会让人以为名单丢了。
continue
}
out = append(out, entryImportSheet{Name: planned.Name, Rows: grouped[key]})
}
return out, nil
}
// entryImportFilename 是两种布局的文件名:中文名给浏览器,ASCII 名给不认 filename* 的老客户端。
func entryImportFilename(format string) (filename, ascii string) {
if format == entryImportFormatSheets {
return "报名名单(导入格式·分小项).xlsx", "entries-import-sheets.xlsx"
}
return "报名名单(导入格式).xlsx", "entries-import.xlsx"
}
// entryImportSheet 是一张待写的工作表:名字 + 该表的数据行(行序即写入顺序)。
type entryImportSheet struct {
Name string
Rows []export.EntryImportRow
}
// writeEntriesImportXLSX 写整份「导入格式」工作簿。
//
// 版式与报名模板刻意一致:**表头就在第 1 行**,没有标题行 / 副标题行——导入器认第 1 行为表头,
// 在它上面加一行标题,用户把这份表填回来时会被判成「报名表缺少列」。
// 列宽照模板给(entryTemplateWidths),但**不挂批注与输入提示**:那是教人填空白模板的东西,
// 数据导出件里挂上去只会挡住看数据的人。
func writeEntriesImportXLSX(out io.Writer, language string, sheets []entryImportSheet) error {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
// 文档属性照导出件那一套(internal/export 的 setDocProps):Excel「文件 → 信息」里
// 不该出现第三方库的作者名(xuri)与一个假日期——这份表是要发给现场、发出去对账的。
now := time.Now().Format(time.RFC3339)
if err := f.SetDocProps(&excelize.DocProperties{
Creator: entryTemplateAuthor, LastModifiedBy: entryTemplateAuthor, Created: now, Modified: now,
}); err != nil {
return fmt.Errorf("httpapi: 设置报名名单文档属性: %w", err)
}
if err := f.SetAppProps(&excelize.AppProperties{Application: entryTemplateAuthor}); err != nil {
return fmt.Errorf("httpapi: 设置报名名单应用属性: %w", err)
}
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11, Family: "微软雅黑", Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"3482FF"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
})
if err != nil {
return fmt.Errorf("httpapi: 创建报名名单表头样式: %w", err)
}
// 表头与列宽都按 importColumns 的列序来,与模板共用同一份实现(改 importColumns 三处一起变)。
headers := entryTemplateHeader(language)
for i, sheet := range sheets {
if i == 0 {
if err := f.SetSheetName("Sheet1", sheet.Name); err != nil {
return fmt.Errorf("httpapi: 重命名报名名单工作表 %s: %w", sheet.Name, err)
}
} else if _, err := f.NewSheet(sheet.Name); err != nil {
return fmt.Errorf("httpapi: 新建报名名单工作表 %s: %w", sheet.Name, err)
}
if err := writeEntryImportHeader(f, sheet.Name, headers, headerStyle); err != nil {
return err
}
for n, row := range sheet.Rows {
for column, value := range entryImportRecord(row) {
cell, err := excelize.CoordinatesToCellName(column+1, n+2)
if err != nil {
return err
}
if err := f.SetCellValue(sheet.Name, cell, value); err != nil {
return err
}
}
}
// 冻结表头:一份名单几十行,往下翻的时候列名得一直看得见(与模板同一版式)。
if err := f.SetPanes(sheet.Name, &excelize.Panes{
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
}); err != nil {
return err
}
}
if err := f.Write(out); err != nil {
return fmt.Errorf("httpapi: 写出报名名单(导入格式): %w", err)
}
return nil
}
// writeEntryImportHeader 写第 1 行表头并设各列列宽。
// 列宽取自模板同一张表(entryTemplateWidths),没登记的列用 defaultEntryTemplateWidth。
func writeEntryImportHeader(f *excelize.File, sheet string, headers []string, style int) error {
for column, title := range headers {
cell, err := excelize.CoordinatesToCellName(column+1, 1)
if err != nil {
return err
}
if err := f.SetCellValue(sheet, cell, title); err != nil {
return err
}
name, err := excelize.ColumnNumberToName(column + 1)
if err != nil {
return err
}
width := float64(defaultEntryTemplateWidth)
if column < len(importColumns) {
if custom, ok := entryTemplateWidths[importColumns[column].name]; ok {
width = custom
}
}
if err := f.SetColWidth(sheet, name, name, width); err != nil {
return err
}
}
if len(headers) == 0 {
return nil
}
last, err := excelize.ColumnNumberToName(len(headers))
if err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", last+"1", style); err != nil {
return err
}
return f.SetRowHeight(sheet, 1, 24)
}
// entryImportRecord 按 importColumns 的列序把一行导成格子值。
//
// **列序只有一个来源**:这里绝不另写一份列序常量,而是逐列按 importColumns 里的列名取值——
// importColumns 增删列或调顺序,这份导出件跟着变,两边的列序不可能分叉。
// importColumns 将来加了新列而这里没接上,会写出一列空格子;
// entries_import_export_internal_test.go 的 TestEntryImportRecordCoversImportColumns 会在那时当场红。
func entryImportRecord(row export.EntryImportRow) []any {
cells := make([]any, 0, len(importColumns))
for _, column := range importColumns {
switch column.name {
case "event_code":
cells = append(cells, row.EventCode)
case "category":
cells = append(cells, row.CategoryName)
case "team_code":
cells = append(cells, row.TeamCode)
case "athlete_name":
cells = append(cells, row.AthleteName)
case "id_card":
cells = append(cells, row.IDCard)
case "gender":
cells = append(cells, row.Gender)
case "age":
cells = append(cells, row.Age)
case "bib":
cells = append(cells, row.Bib)
case "seed":
cells = append(cells, row.Seed)
case "heat":
cells = append(cells, row.Heat)
case "status":
cells = append(cells, row.Status)
case "member_names":
cells = append(cells, row.MemberNames)
case "squad":
cells = append(cells, row.Squad)
default:
cells = append(cells, "")
}
}
return cells
}
@@ -0,0 +1,106 @@
package httpapi
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// 导出件的列序与列名只有一个来源:importColumns。
//
// 这条用例把 entryImportRecord 的每一列都钉住:importColumns 新增一列而这里没接上,
// 那一列会写出一整列空格子——现场拿到的是「报名表缺少列」或悄悄丢数据;
// 顺序被调换则会让姓名写进证件号那一格。两种都必须在这里当场红。
func TestEntryImportRecordCoversImportColumns(t *testing.T) {
row := export.EntryImportRow{
EventID: 1,
EventCode: "E1",
EventName: "100米跑",
CategoryID: 1,
CategoryName: "男子",
TeamCode: "T1",
AthleteName: "张伟",
IDCard: "110101199001011234",
Gender: "男",
Age: "35",
Bib: "007",
Seed: "3",
Heat: "2",
Status: "正常参赛",
MemberNames: "A002",
Squad: "一队",
}
// 每个字段都给一个互不相同的值:列序错了(取了别的字段)也看得出来。
want := map[string]any{
"event_code": "E1",
"category": "男子",
"team_code": "T1",
"athlete_name": "张伟",
"id_card": "110101199001011234",
"gender": "男",
"age": "35",
"bib": "007",
"seed": "3",
"heat": "2",
"status": "正常参赛",
"member_names": "A002",
"squad": "一队",
}
if len(want) != len(importColumns) {
t.Fatalf("importColumns 有 %d 列,本用例的期望表有 %d 条:加了列就要在这里补一条期望值",
len(importColumns), len(want))
}
cells := entryImportRecord(row)
if len(cells) != len(importColumns) {
t.Fatalf("导出的格子数 = %d,期望与 importColumns 一致的 %d 列", len(cells), len(importColumns))
}
for i, column := range importColumns {
expected, ok := want[column.name]
if !ok {
t.Errorf("importColumns 的 %s 列在 entryImportRecord 里没有接上取值(会写出一列空格子)", column.name)
continue
}
if cells[i] != expected {
t.Errorf("第 %d 列(%s)= %v,期望 %v", i+1, column.name, cells[i], expected)
}
}
}
// 表头必须是 entryTemplateHeader 给的那一份(中英各一份,列序与 importColumns 一致)。
// 这里只确认「导出用的就是模板那一份」,不是另写一遍列名——列名再写一遍就会分叉。
func TestEntryImportHeaderComesFromTemplate(t *testing.T) {
for _, language := range []string{"zh", "en"} {
want := entryTemplateHeader(language)
if len(want) != len(importColumns) {
t.Fatalf("%s 表头有 %d 列,importColumns 有 %d 列", language, len(want), len(importColumns))
}
record := entryImportRecord(export.EntryImportRow{})
if len(record) != len(want) {
t.Fatalf("%s 表头与导出格子的列数不一致:%d vs %d", language, len(want), len(record))
}
}
if entryTemplateHeader("zh")[0] != importColumns[0].aliases[0] {
t.Errorf("中文表头第 1 列 = %q,期望 %q", entryTemplateHeader("zh")[0], importColumns[0].aliases[0])
}
if entryTemplateHeader("en")[0] != importColumns[0].name {
t.Errorf("英文表头第 1 列 = %q,期望 %q", entryTemplateHeader("en")[0], importColumns[0].name)
}
}
// flat 布局那张汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析,
// 而汇总表定不出 (小项, 组别),整份会被拒绝。
func TestEntryImportFlatSheetNameHasNoSeparator(t *testing.T) {
if strings.Contains(entryImportFlatSheet, entrySheetSeparator) {
t.Fatalf("flat 布局的表名 %q 里不能有 %q", entryImportFlatSheet, entrySheetSeparator)
}
name, ascii := entryImportFilename(entryImportFormatFlat)
if name != "报名名单(导入格式).xlsx" || ascii != "entries-import.xlsx" {
t.Errorf("flat 的文件名 = %q / %q", name, ascii)
}
name, ascii = entryImportFilename(entryImportFormatSheets)
if name != "报名名单(导入格式·分小项).xlsx" || ascii != "entries-import-sheets.xlsx" {
t.Errorf("sheets 的文件名 = %q / %q", name, ascii)
}
}
@@ -0,0 +1,534 @@
package httpapi_test
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
// 报名名单(导入格式)导出:列名与列序与报名导入模板**逐列一致**,导出来的文件可以直接重导。
//
// 本文件的核心是往返(round-trip):把导出的字节**原样喂给现成的报名导入解析器**
// (POST /api/entries/import,与现场上传的是同一条链),再查库。
// 这是「格式对得上」唯一算数的证明——列序错位、团体行姓名被填上、状态写「已退赛」,
// 任何一处都会在这里当场红,而不是等现场导入时才发现。
// importExportHeader 是导出件的表头(中文口径,缺省语言)。
//
// 刻意在这里**逐字写死**:验的就是「列序与 importColumns 逐列一致」这个对外契约,
// 写成 entryTemplateHeader(language) 的断言等于什么都没验(两边一起改会一起错)。
var importExportHeader = []string{
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
"号码布", "种子", "批次", "状态", "队员名单", "队伍",
}
// importExportEnglishHeader 是 lang=en 的英文规范列名。
var importExportEnglishHeader = []string{
"event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age",
"bib", "seed", "heat", "status", "member_names", "squad",
}
// importRoundTripResult 是导入响应的读数(只取本用例要断言的字段)。
type importRoundTripResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
Notices []string `json:"notices"`
Conflicts []json.RawMessage `json:"conflicts"`
}
// seedImportRoundTrip 造一份覆盖两种布局与全部取值口径的报名名单:
// 个人行(含同一人两个组别)/ 团体行(姓名留空)/ 联队(T1+T2)/ 多队(squad)/
// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。
func seedImportRoundTrip(t *testing.T, env *testEnv) {
t.Helper()
t1 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T1', '第一分公司', '一公司', 1, 1)`)
t2 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T2', '第二分公司', '二公司', 2, 1)`)
athZhang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age)
VALUES ('张伟', '男', ?, '110101199001011234', '007', 35)`, t1)
athLi := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age)
VALUES ('李强', '男', ?, '110101199202022345', 'B001', 33)`, t2)
athWang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib)
VALUES ('王磊', '男', ?, 'A002')`, t1)
athZhao := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib)
VALUES ('赵雷', '男', ?, 'A003')`, t1)
sport := env.insert(t, `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`)
e1 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order)
VALUES (?, 'E1', '100米跑', 'ongoing', 1)`, sport)
e2 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order)
VALUES (?, 'E2', '4×100米接力', 'ongoing', 2)`, sport)
c1 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e1)
c2 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '女子', 2)`, e1)
c3 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e2)
// 个人行:号码布带前导零、有种子、已分批、正常参赛。
// 号码布刻意取运动员档案上那个(导入侧的号码布列既写报名也认人,两边不同会被当成冲突)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status)
VALUES (?, ?, ?, ?, '007', '3', 2, 'registered')`, e1, c1, t1, athZhang)
// 个人行:未分批(heat 为 NULL)、退赛。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (?, ?, ?, ?, 'B001', 'scratched')`, e1, c1, t2, athLi)
// 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (?, ?, ?, '["A002","预备队员"]', '一队', 'dq')`, e1, c1, t1)
// 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status)
VALUES (?, ?, ?, '["临时队员"]', '二队', 'dns')`, e1, c1, t1)
// 个人行:同一个人报第二个组别(号码布重复出现是合法的)。
env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
VALUES (?, ?, ?, ?, '007', 'registered')`, e1, c2, t1, athZhang)
// 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。
joint := env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (?, ?, ?, '["王磊","赵雷"]', 'registered')`, e2, c3, t1)
env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 0)`, joint, t1)
env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 1)`, joint, t2)
// 团体行的队员关联:一条与文字名单里的号码布重复(去重),一条只存在于关联里(必须补进名单)。
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '一队'`, athWang, e1)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '二队'`, athZhao, e1)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 0)`, joint, athWang)
env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 1)`, joint, athZhao)
}
// importExportRow 是导出件里的一行(按表头列序读)。
type importExportRow struct {
eventCode, category, teamCode, athleteName, idCard, gender, age string
bib, seed, heat, status, memberNames, squad string
}
// label 是测试里的行标识:小项 / 组别 / 队伍 / 号码布(四者合起来唯一)。
func (r importExportRow) label() string {
return r.eventCode + "/" + r.category + "/" + r.squad + "/" + r.bib
}
// readImportExportRows 读出一份导出件里的全部数据行,并逐张表核对表头与列宽。
func readImportExportRows(t *testing.T, f *excelize.File) []importExportRow {
t.Helper()
var out []importExportRow
for _, sheet := range f.GetSheetList() {
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
if len(rows) == 0 {
t.Fatalf("工作表 %s 是空的:第 1 行必须是表头", sheet)
}
for i, want := range importExportHeader {
if got := cellAt(rows, 0, i); got != want {
t.Errorf("工作表 %s 表头第 %d 列 = %q,期望 %q", sheet, i+1, got, want)
}
}
for r := 1; r < len(rows); r++ {
out = append(out, importExportRow{
eventCode: cellAt(rows, r, 0), category: cellAt(rows, r, 1),
teamCode: cellAt(rows, r, 2), athleteName: cellAt(rows, r, 3),
idCard: cellAt(rows, r, 4), gender: cellAt(rows, r, 5), age: cellAt(rows, r, 6),
bib: cellAt(rows, r, 7), seed: cellAt(rows, r, 8), heat: cellAt(rows, r, 9),
status: cellAt(rows, r, 10), memberNames: cellAt(rows, r, 11), squad: cellAt(rows, r, 12),
})
}
// 列宽照模板给:号码布那列窄、证件号那列宽,导出即可打印(与下载的模板同一观感)。
if width, err := f.GetColWidth(sheet, "A"); err == nil && width != 14 {
t.Errorf("工作表 %s 的 A 列宽 = %v,期望 14(与模板一致)", sheet, width)
}
if width, err := f.GetColWidth(sheet, "F"); err == nil && width != 8 {
t.Errorf("工作表 %s 的 F 列宽 = %v,期望 8(与模板一致)", sheet, width)
}
// 数据导出件不挂批注与输入提示:那是空模板教填表人的东西。
if comments, err := f.GetComments(sheet); err == nil && len(comments) != 0 {
t.Errorf("工作表 %s 挂了 %d 条批注,数据导出件不该有", sheet, len(comments))
}
}
return out
}
// wantImportExportRows 是这份导出件应当逐字写出来的内容(列序、取值口径全钉在这里)。
func wantImportExportRows(t *testing.T) map[string]importExportRow {
t.Helper()
rows := []importExportRow{
// 个人行:人员字段取运动员档案。
{eventCode: "E1", category: "男子", teamCode: "T1", athleteName: "张伟",
idCard: "110101199001011234", gender: "男", age: "35",
bib: "007", seed: "3", heat: "2", status: "正常参赛"},
// 未分批:批次留空;退赛写导入器认的中文口径。
{eventCode: "E1", category: "男子", teamCode: "T2", athleteName: "李强",
idCard: "110101199202022345", gender: "男", age: "33",
bib: "B001", status: "退赛"},
// 团体行:姓名必须留空(填了就会被当成个人报名),队员名单是这一行的内容。
{eventCode: "E1", category: "男子", teamCode: "T1", bib: "", squad: "一队",
status: "取消资格", memberNames: "A002,预备队员"},
{eventCode: "E1", category: "男子", teamCode: "T1", squad: "二队",
status: "未出场", memberNames: "临时队员,A003"},
// 同一个人报第二个组别:号码布照样写出来(导入侧按证件号认得出是一个人)。
{eventCode: "E1", category: "女子", teamCode: "T1", athleteName: "张伟",
idCard: "110101199001011234", gender: "男", age: "35", bib: "007", status: "正常参赛"},
// 联队:单位编码写 A+B(与导入表的写法逐字一致),文字名单后面补上队员的号码布。
{eventCode: "E2", category: "男子", teamCode: "T1+T2", status: "正常参赛",
memberNames: "王磊,赵雷,A002,A003"},
}
out := make(map[string]importExportRow, len(rows))
for _, r := range rows {
out[r.label()] = r
}
return out
}
func TestEntriesImportExportRoundTrip(t *testing.T) {
for _, format := range []string{"flat", "sheets"} {
t.Run(format, func(t *testing.T) {
env := newTestEnv(t)
seedImportRoundTrip(t, env)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
before := snapshotEntries(t, env)
status, respHeader, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format)
if status != http.StatusOK {
t.Fatalf("导出报名名单(导入格式)应 200,得到 %d %s", status, body)
}
if format == "sheets" {
assertContentDisposition(t, respHeader.Get("Content-Disposition"),
"entries-import-sheets.xlsx", "报名名单(导入格式·分小项).xlsx")
} else {
assertContentDisposition(t, respHeader.Get("Content-Disposition"),
"entries-import.xlsx", "报名名单(导入格式).xlsx")
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
assertImportExportSheets(t, f, format)
exported := readImportExportRows(t, f)
want := wantImportExportRows(t)
if len(exported) != len(want) {
t.Fatalf("导出行数 = %d,期望 %d(%+v)", len(exported), len(want), exported)
}
for _, row := range exported {
expected, ok := want[row.label()]
if !ok {
t.Errorf("导出件里多出一行 %s:%+v", row.label(), row)
continue
}
if row != expected {
t.Errorf("导出件里 %s = %+v,期望 %+v", row.label(), row, expected)
}
}
// 把导出的字节原样喂给报名导入解析器(与现场上传的是同一条链)。
status, resp := env.uploadEntries(t, client, "entries.xlsx", body)
if status != http.StatusOK || !resp.OK {
t.Fatalf("重新导入导出件应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[importRoundTripResult](t, resp)
// 断言一:解析零错误(状态列写「已退赛」这类对不上的口径会在这里现形)。
if len(result.Errors) != 0 {
t.Fatalf("重新导入必须零错误,得到 %v", result.Errors)
}
if len(result.Conflicts) != 0 {
t.Fatalf("重新导入不该产生冲突(逐行都该命中库里那条报名),得到 %+v", result.Conflicts)
}
if result.Skipped != 0 || result.Created != 0 {
t.Fatalf("重新导入应逐行更新已有报名:created=%d skipped=%d(不能新建、不能跳过)",
result.Created, result.Skipped)
}
// 断言二:导入出来的行数与导出行数一致。
if result.Updated != len(exported) {
t.Fatalf("更新行数 = %d,期望 %d(与导出行数一致)", result.Updated, len(exported))
}
// 断言二(续):逐行核对库里的报名与原库逐项一致,条数一条不差。
assertEntriesUnchanged(t, before, snapshotEntries(t, env))
// 断言三:团体行没有被变成个人报名。
assertTeamRowsStayTeam(t, env)
})
}
}
// assertImportExportSheets 核对两种布局的工作表结构与文档属性。
func assertImportExportSheets(t *testing.T, f *excelize.File, format string) {
t.Helper()
// 文档属性不能留第三方库的作者名(excelize 缺省写的是 xuri)与一个假日期。
props, err := f.GetDocProps()
if err == nil {
if props.Creator != "赛事积分榜系统" {
t.Errorf("文档属性里的作者 = %q,期望 赛事积分榜系统", props.Creator)
}
} else {
t.Errorf("读文档属性: %v", err)
}
sheets := f.GetSheetList()
if format == "flat" {
// 汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析。
if len(sheets) != 1 || sheets[0] != "报名名单" {
t.Fatalf("flat 布局的工作表 = %v,期望只有一张「报名名单」", sheets)
}
return
}
want := []string{"100米跑@男子", "100米跑@女子", "4×100米接力@男子"}
if len(sheets) != len(want) {
t.Fatalf("sheets 布局的工作表 = %v,期望 %v(不能多出说明表)", sheets, want)
}
for i, name := range want {
if sheets[i] != name {
t.Errorf("第 %d 张工作表 = %q,期望 %q(表名由 entrySheetName 算,与导入侧同源)", i+1, sheets[i], name)
}
if !strings.Contains(sheets[i], "@") {
t.Errorf("分小项布局的表名必须带 @:%q", sheets[i])
}
}
}
// assertTeamRowsStayTeam 断言团体行重导后仍是团体报名:athlete_id 为空、队员关联一个不少。
// 这是本格式最容易踩坏的一处——姓名那一格一旦被填上,导入侧就会把它当个人报名。
func assertTeamRowsStayTeam(t *testing.T, env *testEnv) {
t.Helper()
cases := []struct {
eventCode, category, squad string
members int
}{
{"E1", "男子", "一队", 1},
{"E1", "男子", "二队", 1},
{"E2", "男子", "", 2},
}
for _, tc := range cases {
var (
entryID int64
athleteID sql.NullInt64
)
if err := env.ev.QueryRow(`
SELECT en.id, en.athlete_id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN event_categories c ON c.id = en.category_id
WHERE ev.code = ? AND c.name = ? AND en.squad = ?`,
tc.eventCode, tc.category, tc.squad).Scan(&entryID, &athleteID); err != nil {
t.Fatalf("查 %s %s %q 的报名: %v", tc.eventCode, tc.category, tc.squad, err)
}
if athleteID.Valid {
t.Errorf("%s %s %q 被当成了个人报名:athlete_id = %d(团体行的姓名列必须是空的)",
tc.eventCode, tc.category, tc.squad, athleteID.Int64)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes WHERE entry_id = ?`, entryID); n != tc.members {
t.Errorf("%s %s %q 的队员关联 = %d 名,期望 %d 名(队员不能因为重导而掉队)",
tc.eventCode, tc.category, tc.squad, n, tc.members)
}
}
}
// entryState 是一条报名在库里的全部可比字段(往返前后逐项比对)。
//
// 覆盖的正是本格式承载的东西:小项 / 组别 / 单位编码 / 人员 / 性别 / 年龄 / 号码布 / 种子 /
// 批次 / 状态 / 队伍,外加是否个人报名、联队份额与队员关联。
type entryState struct {
eventCode, category, teamCode string
athleteName, gender, age string
bib, seed, heat, status, squad string
memberNames string
personal bool
shares, members string
}
// snapshotEntries 读全库的报名状态,按报名 id 归组(新建出重复报名会让条数变多)。
//
// 份额那一列有意把「没有份额行」折算成主单位独占:库里的约定是两者等价
// (迁移 0024:没有行 = 主单位 100%),而导入侧整组重写份额时会把这一条补上。
// 不折算的话,一次语义上什么都没改的重导会看起来像改过库。
func snapshotEntries(t *testing.T, env *testEnv) map[int64]entryState {
t.Helper()
rows, err := env.ev.Query(`
SELECT en.id, COALESCE(ev.code, ''), COALESCE(c.name, ''), COALESCE(t.code, ''),
COALESCE(a.name, ''), COALESCE(a.gender, ''), COALESCE(CAST(a.age AS TEXT), ''),
COALESCE(en.bib, ''), COALESCE(en.seed, ''), COALESCE(CAST(en.heat AS TEXT), ''),
en.status, COALESCE(en.member_names, ''), COALESCE(en.squad, ''),
en.athlete_id IS NOT NULL,
COALESCE(NULLIF((SELECT group_concat(team_id) FROM (SELECT s.team_id FROM entry_team_shares s
WHERE s.entry_id = en.id ORDER BY s.sort_order, s.team_id)), ''), CAST(en.team_id AS TEXT)),
COALESCE((SELECT group_concat(athlete_id) FROM (SELECT ea.athlete_id FROM entry_athletes ea
WHERE ea.entry_id = en.id ORDER BY ea.athlete_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
ORDER BY en.id`)
if err != nil {
t.Fatalf("读报名快照: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]entryState)
for rows.Next() {
var (
id int64
state entryState
)
if err := rows.Scan(&id, &state.eventCode, &state.category, &state.teamCode,
&state.athleteName, &state.gender, &state.age,
&state.bib, &state.seed, &state.heat, &state.status,
&state.memberNames, &state.squad, &state.personal,
&state.shares, &state.members); err != nil {
t.Fatalf("扫描报名快照: %v", err)
}
out[id] = state
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历报名快照: %v", err)
}
return out
}
// assertEntriesUnchanged 断言重导后库里的报名与原库逐项一致(条数一条不差)。
//
// 唯一允许的两处差异,都是这个格式**有意**带来的、不丢信息的差异:
// - 队员名单只增不减:导出时把 entry_athletes 里已关联的队员补进了那一格
// (否则界面后补的队员在重导后就没了),导入后会多出这些号码布;
// - 份额行会被补成「主单位独占 100%」:库里没有份额行本来就等价于它。
func assertEntriesUnchanged(t *testing.T, before, after map[int64]entryState) {
t.Helper()
if len(after) != len(before) {
t.Fatalf("重新导入后报名条数 = %d,期望 %d(不能新建、不能删除)", len(after), len(before))
}
for id, want := range before {
got, ok := after[id]
if !ok {
t.Errorf("报名 %d 在重新导入后不见了(往返必须逐行命中已有报名)", id)
continue
}
for _, diff := range []string{
diffText("小项", want.eventCode, got.eventCode),
diffText("组别", want.category, got.category),
diffText("单位编码", want.teamCode, got.teamCode),
diffText("姓名", want.athleteName, got.athleteName),
diffText("性别", want.gender, got.gender),
diffText("年龄", want.age, got.age),
diffText("号码布", want.bib, got.bib),
diffText("种子", want.seed, got.seed),
diffText("批次", want.heat, got.heat),
diffText("状态", want.status, got.status),
diffText("队伍", want.squad, got.squad),
diffText("份额", want.shares, got.shares),
diffText("队员关联", want.members, got.members),
} {
if diff != "" {
t.Errorf("报名 %d:%s", id, diff)
}
}
if want.personal != got.personal {
t.Errorf("报名 %d 的报名类型变了:个人 = %v → %v(团体行被当成个人报名 / 反过来都不行)",
id, want.personal, got.personal)
}
// 队员名单只增不减:原来的每一段(文字或号码布)都必须还在。
for _, token := range memberTokensOf(want.memberNames) {
if !containsToken(memberTokensOf(got.memberNames), token) {
t.Errorf("报名 %d 的队员名单丢了 %q:%q → %q", id, token, want.memberNames, got.memberNames)
}
}
}
}
// diffText 拼一条字段差异;两边一样时返回空串。
func diffText(field, before, after string) string {
if before == after {
return ""
}
return fmt.Sprintf("%s 变了:%q → %q", field, before, after)
}
// memberTokensOf 把库里的队员名单拆成一段段:正常是 JSON 数组文本,
// 历史上被手工写成别的样子时整格当一段(与导出侧的读法同口径)。
func memberTokensOf(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var tokens []string
if err := json.Unmarshal([]byte(raw), &tokens); err != nil {
return []string{raw}
}
return tokens
}
func containsToken(tokens []string, want string) bool {
for _, token := range tokens {
if strings.TrimSpace(token) == want {
return true
}
}
return false
}
// format / lang 的口径,以及空名单时仍然给一张带表头的表。
func TestEntriesImportExportFormatAndLanguage(t *testing.T) {
env := newTestEnv(t)
seedImportRoundTrip(t, env)
// guest 即可下载:权限与报表导出(entries.xlsx)同档。
client, _ := env.client()
status, _, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format=csv")
if status != http.StatusBadRequest {
t.Fatalf("非法 format 应 400,得到 %d %s", status, body)
}
if msg := string(body); !strings.Contains(msg, "flat") || !strings.Contains(msg, "sheets") {
t.Errorf("非法 format 的提示要写清只认 flat / sheets,得到 %s", msg)
}
// lang 只有 en 走英文表头,其余一律中文(与模板下载的两个按钮同一口径)。
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=en")
if status != http.StatusOK {
t.Fatalf("lang=en 应 200,得到 %d", status)
}
assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportEnglishHeader)
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=fr")
if status != http.StatusOK {
t.Fatalf("lang=fr 应 200(其余一律中文),得到 %d", status)
}
assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportHeader)
// 筛出来是空的也要给一张带表头的表:拿到手的人一眼看得出是「这次没有数据」。
for _, format := range []string{"flat", "sheets"} {
status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format+"&heat=99")
if status != http.StatusOK {
t.Fatalf("%s 空名单应 200,得到 %d %s", format, status, body)
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
assertHeaderRow(t, f, "报名名单", importExportHeader)
if sheets := f.GetSheetList(); len(sheets) != 1 || sheets[0] != "报名名单" {
t.Errorf("%s 空名单的工作表 = %v,期望只有一张「报名名单」", format, sheets)
}
}
}
// assertHeaderRow 读一张表的第 1 行并逐列核对(同时确认没有标题行占位)。
func assertHeaderRow(t *testing.T, f *excelize.File, sheet string, want []string) {
t.Helper()
defer func() { _ = f.Close() }()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
if len(rows) == 0 {
t.Fatalf("工作表 %s 没有表头行", sheet)
}
for i, header := range want {
if got := cellAt(rows, 0, i); got != header {
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header)
}
}
}
@@ -62,6 +62,9 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
// 报名名单:按小项 / 组别 / 单位 / 批次筛选,四者可单传也可全不传(= 全部)。
mux.HandleFunc("GET /api/export/entries.xlsx", s.read(auth.RoleGuest, s.exportEntriesXLSX))
// 报名名单(导入格式):列序与列名与报名导入模板逐列一致,导出的文件可以直接重新导入。
// 筛选参数与上面那份报表完全一样;format=flat(一张表汇总,缺省)| sheets(按小项@组别分表)。
mux.HandleFunc("GET /api/export/entries-import.xlsx", s.read(auth.RoleGuest, s.exportEntriesImportXLSX))
// 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 sheet 工作簿,导入按编码 upsert。
// 导出与其它导出同档(基础数据本身在 /api/teams、/api/sports 等接口就是公开可读的);
// 导入是批量写、影响面比录入大,权限收到 admin。