feat: 人员唯一标识改为证件号 + 导入冲突逐条决策
需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 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 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
This commit is contained in:
1 parent
c9d5b8ea89
commit
24fff8dd08
21 files changed
+1590
-67
No files matched your search
@@ -85,3 +85,58 @@ func TestEventWritesWriteChangeLog(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user