需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存) - athleteDTO/CRUD 读写 idCard,重复返回 409 - 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号 - 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员 - 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建) - 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new - 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引 前端: - 运动员表单与列表新增「证件号」,搜索支持证件号 - 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮 + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见 - 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示 - 新增 import-conflicts.test.ts 等,前端测试 39/39 文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。 注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
143 lines
5.8 KiB
Go
143 lines
5.8 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
|
)
|
|
|
|
// 单位增删改必须写 change_log,否则 broadcastChanges 推空、大屏单位名不刷新。
|
|
func TestTeamWritesWriteChangeLogAndBroadcast(t *testing.T) {
|
|
fake := &fakeRealtime{}
|
|
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
|
|
fake.db = env.ev
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
|
"code": "C", "name": "第三分公司", "active": true,
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("建单位应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "teams", "upsert"); got != 1 {
|
|
t.Fatalf("建单位应写 1 条 teams change_log,得到 %d", got)
|
|
}
|
|
created := decodeData[map[string]any](t, env2)
|
|
teamID := int64(created["id"].(float64))
|
|
|
|
found := false
|
|
for _, ch := range fake.firstBatch(t) {
|
|
if ch.Entity == "teams" && ch.Action == "upsert" && ch.ID == teamID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("建单位后应广播 teams 变更")
|
|
}
|
|
|
|
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil, true); status != http.StatusOK {
|
|
t.Fatalf("删单位应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "teams", "void"); got != 1 {
|
|
t.Fatalf("删单位应写 1 条 teams void 变更,得到 %d", got)
|
|
}
|
|
}
|
|
|
|
// 项目、组别、通道的写操作同样要推 change_log,大屏才会刷新。
|
|
func TestEventWritesWriteChangeLog(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
|
"sportId": ids.sport, "code": "M400", "name": "400米跑",
|
|
"eventType": "individual", "resultType": "time", "compare": "asc", "scored": true,
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "events", "upsert"); got != 1 {
|
|
t.Fatalf("建项目应写 1 条 events change_log,得到 %d", got)
|
|
}
|
|
created := decodeData[map[string]any](t, env2)
|
|
eventID := int64(created["id"].(float64))
|
|
|
|
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", eventID), map[string]any{
|
|
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("替换组别应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "events", "upsert"); got != 2 {
|
|
t.Fatalf("替换组别应再写 1 条 events change_log,得到 %d", got)
|
|
}
|
|
|
|
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true); status != http.StatusOK {
|
|
t.Fatalf("删项目应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "events", "void"); got != 1 {
|
|
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
|
|
}
|
|
}
|
|
|
|
// 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。
|
|
func TestAthleteIDCardUnique(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
|
"name": "张三", "gender": "男", "teamId": ids.teamA, "idCard": "110101199001011234", "active": true,
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
created := decodeData[map[string]any](t, env2)
|
|
if created["idCard"] != "110101199001011234" {
|
|
t.Fatalf("建人员应返回 idCard: %+v", created)
|
|
}
|
|
athleteID := int64(created["id"].(float64))
|
|
|
|
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
|
"name": "李四", "gender": "男", "teamId": ids.teamA, "idCard": "110101199001011234", "active": true,
|
|
}, true)
|
|
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
|
|
t.Fatalf("重复证件号应 409 CONFLICT,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
|
|
"name": "李强", "gender": "男", "teamId": ids.teamB, "idCard": "110101199001011234", "active": true,
|
|
}, true)
|
|
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
|
|
t.Fatalf("改人员到重复证件号应 409,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
// 空证件号是合法的(历史数据),多条空值不能互相冲突。
|
|
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
|
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
|
|
}, true)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("空证件号应可建,得到 %d %+v", status, env2.Error)
|
|
}
|
|
env.exec(t, `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
|
|
env.exec(t, `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
|
|
if _, err := env.ev.Exec(
|
|
`INSERT INTO athletes (name, team_id, id_card, active) VALUES ('丙', ?, '110101199001011234', 1)`, ids.teamA); err == nil {
|
|
t.Fatalf("唯一索引应拒绝重复证件号")
|
|
}
|
|
|
|
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
|
|
got := decodeData[map[string]any](t, env2)
|
|
if status != http.StatusOK || got["idCard"] != "110101199001011234" {
|
|
t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got)
|
|
}
|
|
}
|