feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
This commit is contained in:
1 parent
b74768b7d5
commit
1b8a37ff8c
32 files changed
+3302
-233
No files matched your search
@@ -801,13 +801,14 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里两个不同的人共用一个号码布 → 该行报错并跳过;同一个人报多个组别时号码布可以重复。
|
||||
// 同一文件里两个不同的人(证件号都填了且对不上)共用一个号码布 → 该行报错并跳过。
|
||||
// 号码布是现场认人的依据,但它同时是一人多项目的合法重复值,只有证件号能证明这是两个人。
|
||||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,,B2001,,registered,\n" +
|
||||
"M100,男子,A,李四,,男,,B2001,,registered,\n"
|
||||
"M100,男子,A,张三,110101199001011234,男,,B2001,,registered,\n" +
|
||||
"M100,男子,A,李四,110101199002021234,男,,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
@@ -830,6 +831,162 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行都没填证件号、分属不同小项 = 同一个人报多个项目。
|
||||
// 证件号是可选列、现场常整列留空,这里**不能**按「号码布重复」拦下整块表:
|
||||
// 两行都落库、只建一个人员,另给一条提示说明号码布出现两次。
|
||||
func TestImportRepeatedBibWithoutIDCardIsOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
secondEvent := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, secondEvent)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
|
||||
"M200,男子,A,张三,,男,26,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一个人两个小项都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("号码布相同但证件号都没填,不该报重号: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "号码布 B2001 在本表中出现 2 次,证件号没填齐,已视为同一人报名多个项目"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("应给一条号码布重复提示 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
// 号码布是同一个人的标识:只应建 1 份档案,两条报名挂在同一个人身上。
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Fatalf("同一个人报两个小项应各有一条报名,得到 %d", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行证件号也相同 → 同一个人报多个项目,照旧放行;
|
||||
// 该说的提示由既有那条「证件号出现 N 次」承担,不要再叠一条号码布的,免得同一件事说两遍。
|
||||
func TestImportRepeatedBibWithSameIDCardIsOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,26,B2001,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,26,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一个人两个组别都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同一个人共用号码布不该报重号: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("只应给证件号那条提示 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行都没填证件号、又落在同一个小项同一个组别 = 真重复报名。
|
||||
// 口径:同一个人在同一组别只保留一条报名(后一行覆盖前一行),绝不建两条;
|
||||
// 因为多半是表格填错,所以给一条提示点名「同一小项同一组别有重复行」。
|
||||
func TestImportSameBibSameScopeTwiceKeepsOneEntry(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,dns,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行新建、次行更新同一条报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同一人同一组别的重复行不该报行级错误: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "号码布 B2001 在本表中出现 2 次,其中同一小项同一组别有重复行:" +
|
||||
"同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("应提示同一组别重复 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 1 {
|
||||
t.Fatalf("同一个人同一组别只应有一条报名,得到 %d", entries)
|
||||
}
|
||||
// 留下的那条是次行的值:后一行覆盖前一行。
|
||||
var entryStatus string
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT status FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entryStatus); err != nil {
|
||||
t.Fatalf("查询报名状态: %v", err)
|
||||
}
|
||||
if entryStatus != "dns" {
|
||||
t.Fatalf("同一条报名应取后一行的状态 dns,得到 %s", entryStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布为空的行不进号码布判重:既不会与其他行串成重号,也不会多出号码布提示,
|
||||
// 该行自己的身份校验(证件号或号码布二选一)一字不变。
|
||||
func TestImportRowsWithoutBibUnaffected(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,,,registered,\n" +
|
||||
"M100,女子,A,李四,110101199003031234,女,24,,,registered,\n" +
|
||||
"M100,女子,A,王五,,女,25,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("两条无号码布的行照常导入,缺身份的那行照旧跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "必须填 id_card 或 bib") {
|
||||
t.Fatalf("缺证件号又没有号码布的行应报既有那条错误: %+v", errs)
|
||||
}
|
||||
if notices, _ := result["notices"].([]any); len(notices) != 0 {
|
||||
t.Fatalf("号码布只出现一次,不该有提示: %+v", notices)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||||
// 决策 keep-new 后号码布按表里的值更新。
|
||||
|
||||
Reference in new issue
Block a user