feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
This commit is contained in:
1 parent
b74768b7d5
commit
1b8a37ff8c
32 files changed
+3302
-233
No files matched your search
@@ -762,8 +762,19 @@ const (
|
||||
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||||
const maxEntryImportBytes = 32 << 20
|
||||
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||||
// importSheet 是解析后的一张待导入表。
|
||||
//
|
||||
// CSV 与「单表 xlsx」只有一张、名字为空串:这类文件的报错文案不加工作表前缀,
|
||||
// 与升级前逐字一致(现场手里几百份老表的报错格式不该因为这次改动变样)。
|
||||
type importSheet struct {
|
||||
name string
|
||||
rows [][]string
|
||||
// hidden 表示这是 xlsx 里的隐藏工作表:不导入,但要在结果里说一句(见 prepareImportTables)。
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格里的全部工作表。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([]importSheet, error) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return nil, badRequest("上传文件解析失败", err.Error())
|
||||
@@ -773,16 +784,16 @@ func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]str
|
||||
return nil, badRequest("缺少 file 字段", err.Error())
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
return readTable(filepath.Ext(header.Filename), file)
|
||||
return readTableSheets(filepath.Ext(header.Filename), file)
|
||||
}
|
||||
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns,分工作表模板见 entries_sheet_template.go。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
sheets, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.importEntries(r.Context(), rows)
|
||||
result, err := s.importEntriesWith(r.Context(), sheets, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -796,7 +807,7 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
|
||||
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
|
||||
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
sheets, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -819,7 +830,7 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
}
|
||||
byKey[key] = d.Action
|
||||
}
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byKey)
|
||||
result, err := s.importEntriesWith(r.Context(), sheets, byKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -842,7 +853,11 @@ func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
// readTableSheets 读出表格里的全部工作表(xlsx 按工作簿顺序;CSV 只有一张、名字为空串)。
|
||||
//
|
||||
// 单表 CSV / 单表 xlsx 的解析结果与升级前完全一样(同样的一张表、同一套表头口径),
|
||||
// 变化只在 xlsx 多读了几张表——分工作表模板要用得到,见 prepareImportTables。
|
||||
func readTableSheets(ext string, file io.Reader) ([]importSheet, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
f, err := excelize.OpenReader(file)
|
||||
@@ -850,15 +865,30 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
return nil, badRequest("无法解析 xlsx", err.Error())
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
names := f.GetSheetList()
|
||||
if len(names) == 0 {
|
||||
return nil, badRequest("xlsx 没有工作表", nil)
|
||||
}
|
||||
rows, err := f.GetRows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||||
out := make([]importSheet, 0, len(names))
|
||||
hidden := 0
|
||||
for _, name := range names {
|
||||
// 隐藏工作表按约定不是给人填的数据(工具生成的辅助表常见),不参与导入;
|
||||
// 但要在结果里说一句,免得「有一张表没导进去」变成静默丢数据。
|
||||
sheetHidden := false
|
||||
if visible, err := f.GetSheetVisible(name); err == nil && !visible {
|
||||
sheetHidden = true
|
||||
hidden++
|
||||
}
|
||||
rows, err := f.GetRows(name)
|
||||
if err != nil {
|
||||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||||
}
|
||||
out = append(out, importSheet{name: name, rows: rows, hidden: sheetHidden})
|
||||
}
|
||||
return rows, nil
|
||||
if hidden == len(out) {
|
||||
return nil, badRequest("xlsx 里的工作表都是隐藏的,没有可导入的数据", nil)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
text, err := decodeTableText(file)
|
||||
if err != nil {
|
||||
@@ -873,7 +903,7 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
|
||||
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
return []importSheet{{rows: rows}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,40 +1132,52 @@ func importColumnIndex(header []string) (map[string]int, error) {
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
return s.importEntriesWith(ctx, rows, nil)
|
||||
// importTable 是一张进入导入流程的表。
|
||||
//
|
||||
// scope 为 nil 表示单表口径:范围由表内的「项目编码 / 组别」两列决定(CSV 与单表 xlsx 都走这条)。
|
||||
// scope 非 nil 表示分工作表口径:范围由工作表名给定,表内那两列可以留空,填了就必须与工作表名一致。
|
||||
type importTable struct {
|
||||
sheet string
|
||||
rows [][]string
|
||||
scope *entrySheetScope
|
||||
}
|
||||
|
||||
// bibOwner 汇总一个号码布在一份报名表里被哪些行用过,用来区分「同一个人报多个项目」与
|
||||
// 「两个人在抢同一个号码布」。
|
||||
//
|
||||
// 判据是证件号:号码布是现场认人的依据,但它同时是一人多项目的合法重复值,只有证件号能证明
|
||||
// 这是两个不同的人(两边都填了且对不上)。证件号相同、或有一方没填——后者是现场的常态,
|
||||
// 证件号列常整列留空——都算同一个人,只在收尾用 notices 提示,绝不拦下这些行。
|
||||
type bibOwner struct {
|
||||
idCard string // 第一次见到的非空证件号;空表示这些行都没填
|
||||
team bool // 这个号码布落在团体行上(团体行没有「人」,重复一律报错)
|
||||
rows int // 用过这个号码布、且已放行的行数
|
||||
scope entryScope // 第一行落在哪个 (小项, 组别)
|
||||
scopes map[entryScope]bool // 第二行起记放行行落到过哪些 (小项, 组别)
|
||||
unproven bool // 放行靠的是「证件号没填齐」,而不是证件号对得上
|
||||
repeatScope bool // 有第二行落在同一个 (小项, 组别) 上 = 同一人同一组别重复报名
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
//
|
||||
// sheets 是上传文件里的全部工作表:CSV 与单表 xlsx 只有一张(名字为空),分工作表模板则一张表一个
|
||||
// (小项, 组别)。两种口径在这份代码里往下走同一条路,于是人员匹配、号码布查重、跨行去重这些判据
|
||||
// (bibOwners / idCardCounts / seenIdentities)天然是**跨工作表**的:同一个号码布出现在两张表里,
|
||||
// 照样按「报名表内重号」处理。
|
||||
func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{
|
||||
Errors: []string{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
index, err := importColumnIndex(rows[0])
|
||||
tables, notices, err := s.prepareImportTables(ctx, sheets)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
result.Notices = append(result.Notices, notices...)
|
||||
|
||||
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
|
||||
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
|
||||
// 空串有专门含义:登记者没有证件号(个人行没填,或本来就是团体行),复用它一律按重号处理。
|
||||
bibOwners := make(map[string]string)
|
||||
// 号码布是现场认定的「人」:同一个人报多个项目时它会重复出现。判重只认证件号能不能
|
||||
// 证明这是两个人(见 bibOwner),所以这里记的是号码布 -> 用过它的那些行。
|
||||
bibOwners := make(map[string]bibOwner)
|
||||
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
|
||||
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
|
||||
idCardCounts := make(map[string]int)
|
||||
@@ -1144,188 +1186,283 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
|
||||
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
|
||||
affectedScopes := make([]entryScope, 0, 2)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
eventCode := cell("event_code")
|
||||
categoryName := cell("category")
|
||||
teamCode := cell("team_code")
|
||||
athleteName := cell("athlete_name")
|
||||
if eventCode == "" || teamCode == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
|
||||
continue
|
||||
}
|
||||
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
|
||||
status, err := normalizeImportStatus(cell("status"))
|
||||
for _, table := range tables {
|
||||
index, err := importColumnIndex(table.rows[0])
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
return result, withSheetPrefix(table.sheet, err)
|
||||
}
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
// 分工作表口径下范围由工作表名承担:「项目编码 / 组别」两列可以整列不要。
|
||||
if table.scope != nil && (column.name == "event_code" || column.name == "category") {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, withSheetPrefix(table.sheet, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil))
|
||||
}
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
// 行级报错在工作表口径下带上表名:几张表各有自己的「第 3 行」,只说行号现场认不出是哪张表。
|
||||
// 单表 / CSV 的 prefix 是空串,报错文案与升级前逐字一致。
|
||||
prefix := ""
|
||||
if table.sheet != "" {
|
||||
prefix = "[工作表 " + table.sheet + "] "
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
|
||||
continue
|
||||
}
|
||||
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
var categoryID int64
|
||||
if categoryName != "" {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
|
||||
for n, row := range table.rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
eventCode := cell("event_code")
|
||||
categoryName := cell("category")
|
||||
teamCode := cell("team_code")
|
||||
athleteName := cell("athlete_name")
|
||||
if teamCode == "" || (table.scope == nil && eventCode == "") {
|
||||
result.Skipped++
|
||||
if table.scope == nil {
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "event_code / team_code 必填"))
|
||||
} else {
|
||||
// 分工作表口径下小项由工作表名给定,缺的只可能是单位编码。
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "team_code(单位编码)必填"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
|
||||
status, err := normalizeImportStatus(cell("status"))
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
|
||||
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
|
||||
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
|
||||
idCard := cell("id_card")
|
||||
bib := cell("bib")
|
||||
if bib != "" {
|
||||
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
|
||||
// 团体行没有「同一个人」这回事(它没有证件号可言),因此一律按重号处理。
|
||||
owner := idCard
|
||||
if athleteName == "" {
|
||||
owner = ""
|
||||
}
|
||||
if prev, seen := bibOwners[bib]; seen && (owner == "" || prev != owner) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
bibOwners[bib] = owner
|
||||
}
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
continue
|
||||
}
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
// 范围:单表口径按表里的两列查;分工作表口径由工作表名给定,表里填了就必须与它一致。
|
||||
// 不一致时**报错而不是挑一个用**:两处说法矛盾时系统无从判断该信哪个,
|
||||
// 静默取工作表名会让填错的行悄悄落到别的小项上去。
|
||||
var eventID, categoryID int64
|
||||
if table.scope != nil {
|
||||
eventID, categoryID = table.scope.EventID, table.scope.CategoryID
|
||||
if eventCode != "" {
|
||||
scoped, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
}
|
||||
if scoped != eventID {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"本行项目编码 %s 不是工作表名里那个小项「%s」:分工作表模板里这两处必须说的是同一个小项。"+
|
||||
"请删掉这一格(留空即按工作表名走),或改成本工作表对应的小项编码",
|
||||
eventCode, table.scope.EventName))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if categoryName != "" && !sameImportScopeName(categoryName, table.scope.CategoryName) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"本行组别「%s」与工作表名里的组别「%s」不一致:分工作表模板里这两处必须是同一个组别。"+
|
||||
"请删掉这一格(留空即按工作表名走),或改成工作表名里的组别", categoryName, table.scope.CategoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
eventID, err = s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "单位 %s 不存在", teamCode))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
if table.scope == nil {
|
||||
if categoryName != "" {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "组别 %s 不存在", categoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 没有组别", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(bib),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
|
||||
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
|
||||
for _, moved := range outcome.MovedEntries {
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
|
||||
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
|
||||
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
|
||||
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
|
||||
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
|
||||
//
|
||||
// 个人行之间的重复**只按证件号判**:证件号是人员的唯一标识,号码布不是——同一个人报多个
|
||||
// 项目本来就会带着同一个号码布出现好几行。两边证件号都填了且对不上才算「两个人抢一个号」;
|
||||
// 证件号相同、或有一方没填(现场常不填),都证明不了是两个人,一律当同一个人放行,
|
||||
// 收尾用 notices 说清楚这个号码布出现在几行上,让操作员自己核对。
|
||||
idCard := cell("id_card")
|
||||
bib := cell("bib")
|
||||
if bib != "" {
|
||||
owner, seen := bibOwners[bib]
|
||||
switch {
|
||||
case seen && (owner.team || athleteName == ""):
|
||||
// 团体行没有「人」可言,谈不上「视为同一个人」,一律按重号报错(升级前的行为)。
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
case seen && owner.idCard != "" && idCard != "" && owner.idCard != idCard:
|
||||
// 两个人共用一个号码布:保持升级前的行级报错口径。
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
if seen {
|
||||
// 复用放行:记下这次靠的是「证件号对得上」还是「有一方没填、证明不了」,
|
||||
// 以及第二行起落在哪些 (小项, 组别) 上——同一组别上出现两次是真重复报名。
|
||||
scope := entryScope{eventID, categoryID}
|
||||
if owner.scopes == nil {
|
||||
owner.scopes = map[entryScope]bool{owner.scope: true}
|
||||
}
|
||||
if owner.scopes[scope] {
|
||||
owner.repeatScope = true
|
||||
}
|
||||
owner.scopes[scope] = true
|
||||
owner.unproven = owner.unproven || owner.idCard == "" || idCard == ""
|
||||
} else {
|
||||
owner.scope = entryScope{eventID, categoryID}
|
||||
owner.team = athleteName == ""
|
||||
}
|
||||
if owner.idCard == "" {
|
||||
owner.idCard = idCard
|
||||
}
|
||||
owner.rows++
|
||||
bibOwners[bib] = owner
|
||||
}
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
continue
|
||||
}
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
continue
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(bib),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
|
||||
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
|
||||
for _, moved := range outcome.MovedEntries {
|
||||
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
|
||||
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
|
||||
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
|
||||
}
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同一证件号在本表里出现多次 = 同一个人报多个组别:不报错,但要让现场看见。
|
||||
// 按证件号排序,保证同一份表的提示顺序稳定(前端展示与用例断言都依赖它)。
|
||||
// 分工作表模板下这条判据是**跨表**的:同一个人在两个组别各一行,同样只给一条提示。
|
||||
idCards := make([]string, 0, len(idCardCounts))
|
||||
for idCard := range idCardCounts {
|
||||
idCards = append(idCards, idCard)
|
||||
@@ -1337,6 +1474,29 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
fmt.Sprintf("证件号 %s 在本表中出现 %d 次(同一人报名多个组别)", idCard, count))
|
||||
}
|
||||
}
|
||||
// 号码布重复出现、但证件号证明不了是两个人(有一方或双方没填)时,行放行但要说清楚:
|
||||
// 号码布是现场认人的依据,重复既可能是一人多项目,也可能是表格填错。
|
||||
// 与证件号那条同一个 notices 通道,也按号码布排序,保证同一份表的提示顺序稳定。
|
||||
bibs := make([]string, 0, len(bibOwners))
|
||||
for bib := range bibOwners {
|
||||
bibs = append(bibs, bib)
|
||||
}
|
||||
sort.Strings(bibs)
|
||||
for _, bib := range bibs {
|
||||
owner := bibOwners[bib]
|
||||
if owner.rows < 2 || !owner.unproven {
|
||||
// 证件号对得上(两边都填了同一个号)时不用这条:证件号那条提示已经说过这件事了。
|
||||
continue
|
||||
}
|
||||
if owner.repeatScope {
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"号码布 %s 在本表中出现 %d 次,其中同一小项同一组别有重复行:同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格",
|
||||
bib, owner.rows))
|
||||
continue
|
||||
}
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
|
||||
}
|
||||
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
|
||||
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
|
||||
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
|
||||
|
||||
Reference in new issue
Block a user