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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user