用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package export
|
||
|
||
import (
|
||
"bytes"
|
||
"database/sql"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
)
|
||
|
||
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
|
||
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
|
||
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
|
||
func seedBaseData(t *testing.T, db *sql.DB) {
|
||
t.Helper()
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
|
||
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
|
||
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
|
||
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
|
||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||
record_eligible, scored, multiplier, status, sort_order, venue, remark)
|
||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
|
||
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
|
||
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
|
||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
|
||
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
|
||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
|
||
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
|
||
t.Helper()
|
||
for _, row := range rows {
|
||
if row.Cells[field] == value {
|
||
return row
|
||
}
|
||
}
|
||
t.Fatalf("找不到 %s = %q 的行", field, value)
|
||
return BaseDataRow{}
|
||
}
|
||
|
||
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
|
||
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
|
||
func TestBaseDataPackageRoundTrip(t *testing.T) {
|
||
db := openEventDB(t)
|
||
seedBaseData(t, db)
|
||
|
||
wb, err := LoadBaseDataWorkbook(db, Options{})
|
||
if err != nil {
|
||
t.Fatalf("读取基础数据: %v", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
|
||
t.Fatalf("写出基础数据包: %v", err)
|
||
}
|
||
|
||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||
if err != nil {
|
||
t.Fatalf("读回基础数据包: %v", err)
|
||
}
|
||
if len(tables) != 4 {
|
||
t.Fatalf("工作表数 = %d,期望 4", len(tables))
|
||
}
|
||
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
|
||
wantRows := []int{2, 2, 3, 4}
|
||
for i, table := range tables {
|
||
if table.Sheet != wantSheets[i] {
|
||
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
|
||
}
|
||
if table.HeaderLine != rowHeader {
|
||
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
|
||
}
|
||
if len(table.Rows) != wantRows[i] {
|
||
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
|
||
}
|
||
// 表头必须与导出契约逐字一致,否则导入端认不出列。
|
||
spec := BaseDataSpecs()[i]
|
||
for _, h := range spec.Headers {
|
||
found := false
|
||
for _, row := range table.Rows {
|
||
if row.Has[h.Field] {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found && len(table.Rows) > 0 {
|
||
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
|
||
}
|
||
}
|
||
}
|
||
|
||
teams := tables[0]
|
||
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
|
||
t.Errorf("单位编码 = %q,期望 T1", got)
|
||
}
|
||
t1 := findRecord(t, teams.Rows, "code", "T1")
|
||
if got := t1.Cells["active"]; got != "是" {
|
||
t.Errorf("T1 启用 = %q,期望 是", got)
|
||
}
|
||
if got := t1.Cells["color"]; got != "#3482FF" {
|
||
t.Errorf("T1 颜色 = %q", got)
|
||
}
|
||
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
|
||
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
|
||
}
|
||
|
||
events := tables[2]
|
||
e1 := findRecord(t, events.Rows, "code", "E1")
|
||
if got := e1.Cells["sportCode"]; got != "SP1" {
|
||
t.Errorf("小项大项编码 = %q,期望 SP1", got)
|
||
}
|
||
if got := e1.Cells["multiplier"]; got != "2" {
|
||
t.Errorf("E1 权重 = %q,期望 2", got)
|
||
}
|
||
if got := e1.Cells["venue"]; got != "主体育场" {
|
||
t.Errorf("E1 场地 = %q", got)
|
||
}
|
||
if got := e1.Cells["resultType"]; got != "time" {
|
||
t.Errorf("E1 结果类型 = %q,期望 time", got)
|
||
}
|
||
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
|
||
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
|
||
}
|
||
|
||
cats := tables[3]
|
||
findRecord(t, cats.Rows, "eventCode", "E1")
|
||
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
|
||
if got := c1.Cells["eventCode"]; got != "E1" {
|
||
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
|
||
}
|
||
if got := c1.Cells["ageMin"]; got != "18" {
|
||
t.Errorf("年龄下限 = %q,期望 18", got)
|
||
}
|
||
if got := c1.Cells["gender"]; got != "男" {
|
||
t.Errorf("性别 = %q,期望 男", got)
|
||
}
|
||
if got := c1.Cells["multiplier"]; got != "1.5" {
|
||
t.Errorf("组别权重 = %q,期望 1.5", got)
|
||
}
|
||
|
||
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
|
||
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||
if err != nil {
|
||
t.Fatalf("打开基础数据包: %v", err)
|
||
}
|
||
defer func() { _ = raw.Close() }()
|
||
headerRow, err := raw.GetRows(BaseDataSheetCategories)
|
||
if err != nil {
|
||
t.Fatalf("读取组别表: %v", err)
|
||
}
|
||
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
|
||
if got := cell(headerRow, rowHeader-1, i); got != h {
|
||
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
|
||
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
|
||
f := excelize.NewFile()
|
||
defer func() { _ = f.Close() }()
|
||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||
t.Fatalf("重命名工作表: %v", err)
|
||
}
|
||
rows := [][]string{
|
||
{"单位编码", "单位名称", "启用"},
|
||
{"T9", "第九分公司", "是"},
|
||
{"T8", "第八分公司", "否"},
|
||
}
|
||
for r, row := range rows {
|
||
for c, v := range row {
|
||
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
|
||
if err := f.SetCellValue("单位", name, v); err != nil {
|
||
t.Fatalf("写单元格: %v", err)
|
||
}
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := f.Write(&buf); err != nil {
|
||
t.Fatalf("写出测试工作簿: %v", err)
|
||
}
|
||
|
||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||
if err != nil {
|
||
t.Fatalf("读回: %v", err)
|
||
}
|
||
if len(tables) != 1 || tables[0].Sheet != "单位" {
|
||
t.Fatalf("只应认出单位表,得到 %+v", tables)
|
||
}
|
||
if tables[0].HeaderLine != 1 {
|
||
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
|
||
}
|
||
if len(tables[0].Rows) != 2 {
|
||
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
|
||
}
|
||
if got := tables[0].Rows[0].Line; got != 2 {
|
||
t.Errorf("首行行号 = %d,期望 2", got)
|
||
}
|
||
if got := tables[0].Rows[1].Line; got != 3 {
|
||
t.Errorf("次行行号 = %d,期望 3", got)
|
||
}
|
||
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
|
||
if tables[0].Rows[0].Has["shortName"] {
|
||
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
|
||
}
|
||
if !tables[0].Rows[0].Has["active"] {
|
||
t.Errorf("文件里有启用列,Has[active] 应为 true")
|
||
}
|
||
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
|
||
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
|
||
}
|
||
}
|
||
|
||
func TestReadBaseDataPackageErrors(t *testing.T) {
|
||
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
|
||
f := excelize.NewFile()
|
||
defer func() { _ = f.Close() }()
|
||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||
t.Fatalf("重命名工作表: %v", err)
|
||
}
|
||
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
|
||
t.Fatalf("写单元格: %v", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := f.Write(&buf); err != nil {
|
||
t.Fatalf("写出测试工作簿: %v", err)
|
||
}
|
||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
|
||
t.Fatal("找不到表头应报错")
|
||
} else if !strings.Contains(err.Error(), "单位编码") {
|
||
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
|
||
}
|
||
|
||
// 一张可识别的表都没有。
|
||
f2 := excelize.NewFile()
|
||
defer func() { _ = f2.Close() }()
|
||
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
|
||
t.Fatalf("写单元格: %v", err)
|
||
}
|
||
var buf2 bytes.Buffer
|
||
if err := f2.Write(&buf2); err != nil {
|
||
t.Fatalf("写出测试工作簿: %v", err)
|
||
}
|
||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
|
||
t.Fatal("没有任何可识别工作表应报错")
|
||
}
|
||
}
|
||
|
||
func TestParseBaseDataHelpers(t *testing.T) {
|
||
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
|
||
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
|
||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
|
||
}
|
||
}
|
||
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
|
||
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
|
||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
|
||
}
|
||
}
|
||
if v, ok := ParseBaseDataBool("", true); !ok || !v {
|
||
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
|
||
}
|
||
if _, ok := ParseBaseDataBool("也许", true); ok {
|
||
t.Error("认不出的写法应报不合法")
|
||
}
|
||
|
||
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
|
||
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
|
||
}
|
||
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
|
||
t.Error("非数字权重应报不合法")
|
||
}
|
||
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
|
||
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
|
||
}
|
||
if _, ok := ParseBaseDataInt("三"); ok {
|
||
t.Error("非整数应报不合法")
|
||
}
|
||
}
|