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
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -526,17 +527,48 @@ func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) er
|
||||
// ---------- 报名表导入 ----------
|
||||
|
||||
type entryImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Existing importAthleteView `json:"existing"`
|
||||
Incoming importAthleteView `json:"incoming"`
|
||||
ChangedFields []string `json:"changedFields"`
|
||||
}
|
||||
|
||||
// importAthleteView 是冲突两端可比的字段快照(单位用编码,前端好展示)。
|
||||
type importAthleteView struct {
|
||||
Name string `json:"name"`
|
||||
Gender *string `json:"gender"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
EmployeeNo *string `json:"employeeNo"`
|
||||
}
|
||||
|
||||
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
|
||||
type importDecision struct {
|
||||
IDCard string `json:"idCard"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
const (
|
||||
decisionKeepExisting = "keep-existing"
|
||||
decisionKeepNew = "keep-new"
|
||||
)
|
||||
|
||||
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||||
const maxEntryImportBytes = 32 << 20
|
||||
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return nil, badRequest("上传文件解析失败", err.Error())
|
||||
@@ -546,8 +578,12 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return nil, badRequest("缺少 file 字段", err.Error())
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
return readTable(filepath.Ext(header.Filename), file)
|
||||
}
|
||||
|
||||
rows, err := readTable(filepath.Ext(header.Filename), file)
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -559,6 +595,36 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
|
||||
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
|
||||
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := strings.TrimSpace(r.FormValue("decisions"))
|
||||
if raw == "" {
|
||||
return nil, badRequest("缺少 decisions 字段", nil)
|
||||
}
|
||||
var decisions []importDecision
|
||||
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
|
||||
return nil, badRequest("decisions 不是合法 JSON", err.Error())
|
||||
}
|
||||
byCard := make(map[string]string, len(decisions))
|
||||
for _, d := range decisions {
|
||||
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
|
||||
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
|
||||
}
|
||||
byCard[strings.TrimSpace(d.IDCard)] = d.Action
|
||||
}
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byCard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
@@ -588,13 +654,20 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感)。
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender 为可选列,缺列不报错,按空值处理。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}}
|
||||
return s.importEntriesWith(ctx, rows, nil)
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理证件号命中的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
@@ -608,11 +681,13 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。
|
||||
seenIDCards := make(map[string]bool)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i := index[name]
|
||||
if i >= len(row) {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
@@ -655,11 +730,43 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
|
||||
athleteID = &id
|
||||
idCard := cell("id_card")
|
||||
if idCard == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line))
|
||||
continue
|
||||
}
|
||||
if seenIDCards[idCard] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
@@ -685,6 +792,15 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||
func normalizeGender(raw string) string {
|
||||
switch raw {
|
||||
case "男", "女":
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitNames(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user