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,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。
|
||||
|
||||
Reference in new issue
Block a user