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:
AskaEth committed 2026-09-10 02:09:15 +08:00
1 parent c9d5b8ea89
commit 24fff8dd08
21 files changed
+1590 -67

No files matched your search

+165
View File
@@ -14,6 +14,171 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64
return id, nil
}
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空)。
type athleteImportRow struct {
Line int
Name string
Gender string
TeamID int64
TeamCode string
IDCard string
}
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
type athleteImportMatch struct {
AthleteID int64
TeamID int64
Conflict *importConflict
}
// existingAthlete 是库中已有人员的可比字段。
type existingAthlete struct {
ID int64
Name string
Gender *string
TeamID int64
EmployeeNo *string
}
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 姓名+单位(库中证件号为空则回填)→ 新建。
// 证件号命中但姓名/性别/单位与库中不一致时记为冲突;decisions 里对该证件号有决策时按决策落库。
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
existing, err := s.athleteByIDCard(ctx, in.IDCard)
switch {
case err == nil:
changed := changedAthleteFields(existing, in)
if len(changed) == 0 {
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
}
conflict, err := s.importConflictFor(ctx, in, existing, changed)
if err != nil {
return athleteImportMatch{}, err
}
action, decided := decisions[in.IDCard]
switch {
case !decided:
return athleteImportMatch{Conflict: &conflict}, nil
case action == decisionKeepExisting:
// 人员字段保持库里的值,报名也跟随库里的单位。
return athleteImportMatch{AthleteID: existing.ID, TeamID: existing.TeamID}, nil
default:
if err := s.overwriteAthleteFromImport(ctx, existing.ID, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
}
case errors.Is(err, sql.ErrNoRows):
return s.matchOrCreateAthleteByName(ctx, in)
default:
return athleteImportMatch{}, internal(err)
}
}
// matchOrCreateAthleteByName 处理证件号未命中的情况:
// 姓名+单位命中且库中证件号为空 → 视为同一人并回填证件号;否则新建人员。
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
var id int64
err := s.ev.QueryRowContext(ctx,
`SELECT id FROM athletes WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
ORDER BY id LIMIT 1`, in.Name, in.TeamID).Scan(&id)
switch {
case err == nil:
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, id); err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES (?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, in.IDCard)
if err != nil {
return athleteImportMatch{}, internal(err)
}
id, err = lastInsertID(res)
if err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
}
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
var (
a existingAthlete
gender, empNo sql.NullString
teamID sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, name, gender, team_id, employee_no FROM athletes WHERE id_card = ? LIMIT 1`, idCard).
Scan(&a.ID, &a.Name, &gender, &teamID, &empNo)
if err != nil {
return existingAthlete{}, err
}
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
a.TeamID = teamID.Int64
return a, nil
}
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
var changed []string
if ex.Name != in.Name {
changed = append(changed, "name")
}
// 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
changed = append(changed, "gender")
}
if ex.TeamID != in.TeamID {
changed = append(changed, "teamCode")
}
return changed
}
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) {
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
if err != nil {
return importConflict{}, err
}
return importConflict{
Row: in.Line,
IDCard: in.IDCard,
AthleteID: ex.ID,
Existing: importAthleteView{
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
},
Incoming: importAthleteView{
Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode,
},
ChangedFields: changed,
}, nil
}
func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
if id == 0 {
return "", nil
}
var code string
if err := s.ev.QueryRowContext(ctx, `SELECT code FROM teams WHERE id = ?`, id).Scan(&code); err != nil {
return "", internal(err)
}
return code, nil
}
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
// 表里没填性别时保留库里的值,避免决策后反而丢字段。
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender) WHERE id = ?`,
in.Name, in.TeamID, nullStr(&in.Gender), id); err != nil {
return internal(err)
}
return nil
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
var existing int64