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
+21
-6
@@ -263,9 +263,10 @@
|
|||||||
|
|
||||||
### 10.6 运动员
|
### 10.6 运动员
|
||||||
|
|
||||||
1. 点「新增运动员」,填写姓名 *、工号、所属单位等;
|
1. 点「新增运动员」,填写姓名 *、证件号、性别、工号 / 学号、所属单位等;
|
||||||
2. 「编辑」修改,「停用」后不再出现在新报名选择中;
|
2. **证件号是人员的唯一标识**(如身份证号),用于区分重名;同一个证件号不允许重复,重复会被拒绝。已有历史数据可以留空;
|
||||||
3. 个人项目的报名项会关联到运动员,团体项目可以留空。
|
3. 「编辑」修改,「停用」后不再出现在新报名选择中;
|
||||||
|
4. 个人项目的报名项会关联到运动员,团体项目可以留空。
|
||||||
|
|
||||||
### 10.7 报名导入
|
### 10.7 报名导入
|
||||||
|
|
||||||
@@ -282,9 +283,23 @@
|
|||||||
**表格导入**
|
**表格导入**
|
||||||
|
|
||||||
1. 点「下载 CSV 模板」获取表头样例;
|
1. 点「下载 CSV 模板」获取表头样例;
|
||||||
2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感);
|
2. 表头共 10 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`bib`、`seed`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)与 `gender`(性别)为可选列,缺失不影响导入,其余列必须包含;
|
||||||
3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
3. **只要有姓名的行必须填 `id_card`**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;同一份文件里同一个证件号重复出现时,重复的行会报错;
|
||||||
4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
4. 人员匹配顺序:先按证件号匹配已有人员;没匹配到,再按「姓名 + 单位」匹配且该人员证件号为空,视为同一人并回填证件号(不算冲突);都没匹配到则新建人员;
|
||||||
|
5. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
||||||
|
6. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||||
|
|
||||||
|
**证件号冲突的处理**
|
||||||
|
|
||||||
|
导入时如果某个证件号在系统里已经存在,但表里的姓名、性别或单位与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法:
|
||||||
|
|
||||||
|
1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段;
|
||||||
|
2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」:
|
||||||
|
- **保留已有数据**:人员字段沿用系统里已有的,按这个人的信息建 / 更新报名;
|
||||||
|
- **使用新数据**:用表里的姓名、性别、单位等更新该人员,再建 / 更新报名;
|
||||||
|
3. **没有做决定的冲突不会被写入**,可以稍后再处理;全部处理完后冲突列表清空。
|
||||||
|
|
||||||
|
> 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。
|
||||||
|
|
||||||
号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。
|
号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -118,20 +118,26 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
**Q32:导入规则包后积分变了,能撤销吗?**
|
**Q32:导入规则包后积分变了,能撤销吗?**
|
||||||
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
||||||
|
|
||||||
|
**Q33:导入时提示证件号重复怎么办?**
|
||||||
|
证件号是人员的唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。
|
||||||
|
|
||||||
|
- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。
|
||||||
|
- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 其他
|
## 其他
|
||||||
|
|
||||||
**Q33:系统需要联网吗?**
|
**Q34:系统需要联网吗?**
|
||||||
不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。
|
不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。
|
||||||
|
|
||||||
**Q34:页面提示「示例数据」是什么意思?**
|
**Q35:页面提示「示例数据」是什么意思?**
|
||||||
表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。
|
表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。
|
||||||
|
|
||||||
> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。
|
> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。
|
||||||
|
|
||||||
**Q35:为什么登录 / 写操作偶尔被拒绝?**
|
**Q36:为什么登录 / 写操作偶尔被拒绝?**
|
||||||
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。
|
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。
|
||||||
|
|
||||||
**Q36:数据目录在哪?**
|
**Q37:数据目录在哪?**
|
||||||
由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。
|
由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。
|
||||||
@@ -20,6 +20,7 @@ type athleteDTO struct {
|
|||||||
Gender *string `json:"gender"`
|
Gender *string `json:"gender"`
|
||||||
TeamID int64 `json:"teamId"`
|
TeamID int64 `json:"teamId"`
|
||||||
EmployeeNo *string `json:"employeeNo"`
|
EmployeeNo *string `json:"employeeNo"`
|
||||||
|
IDCard *string `json:"idCard"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -526,17 +527,48 @@ func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) er
|
|||||||
// ---------- 报名表导入 ----------
|
// ---------- 报名表导入 ----------
|
||||||
|
|
||||||
type entryImportResult struct {
|
type entryImportResult struct {
|
||||||
Created int `json:"created"`
|
Created int `json:"created"`
|
||||||
Updated int `json:"updated"`
|
Updated int `json:"updated"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errors []string `json:"errors"`
|
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 开销)。
|
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||||||
const maxEntryImportBytes = 32 << 20
|
const maxEntryImportBytes = 32 << 20
|
||||||
|
|
||||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||||
return nil, badRequest("上传文件解析失败", err.Error())
|
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())
|
return nil, badRequest("缺少 file 字段", err.Error())
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -559,6 +595,36 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
|||||||
return result, nil
|
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) {
|
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||||
switch strings.ToLower(ext) {
|
switch strings.ToLower(ext) {
|
||||||
case ".xlsx":
|
case ".xlsx":
|
||||||
@@ -588,13 +654,20 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// importColumns 是报名表必须包含的表头(大小写不敏感)。
|
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||||
|
// id_card、gender 为可选列,缺列不报错,按空值处理。
|
||||||
var importColumns = []string{
|
var importColumns = []string{
|
||||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
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 {
|
if len(rows) < 2 {
|
||||||
return result, badRequest("报名表为空", nil)
|
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:] {
|
for n, row := range rows[1:] {
|
||||||
line := n + 2
|
line := n + 2
|
||||||
cell := func(name string) string {
|
cell := func(name string) string {
|
||||||
i := index[name]
|
i, ok := index[name]
|
||||||
if i >= len(row) {
|
if !ok || i >= len(row) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(row[i])
|
return strings.TrimSpace(row[i])
|
||||||
@@ -655,11 +730,43 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||||
var athleteID *int64
|
var athleteID *int64
|
||||||
if athleteName != "" {
|
if athleteName != "" {
|
||||||
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
|
idCard := cell("id_card")
|
||||||
athleteID = &id
|
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{
|
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||||
@@ -685,6 +792,15 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||||
|
func normalizeGender(raw string) string {
|
||||||
|
switch raw {
|
||||||
|
case "男", "女":
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func splitNames(raw string) []string {
|
func splitNames(raw string) []string {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package httpapi_test
|
package httpapi_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -10,6 +14,65 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
|
||||||
|
const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names"
|
||||||
|
|
||||||
|
// postImport 以 multipart 上传报名表;decisions 非空时作为 resolve 接口的 decisions 字段。
|
||||||
|
func (e *testEnv) postImport(t *testing.T, client *http.Client, path, content, decisions string) (int, envelope) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile("file", "entries.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造 multipart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("写入报名表: %v", err)
|
||||||
|
}
|
||||||
|
if decisions != "" {
|
||||||
|
if err := mw.WriteField("decisions", decisions); err != nil {
|
||||||
|
t.Fatalf("写入 decisions: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("关闭 multipart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, e.server.URL+path, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造请求: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("上传报名表: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var env envelope
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if len(bytes.TrimSpace(raw)) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
t.Fatalf("解析响应: %v (%s)", err, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, env
|
||||||
|
}
|
||||||
|
|
||||||
|
// recorderWithSeed 建好种子数据并返回已登录的 recorder 客户端。
|
||||||
|
func recorderWithSeed(t *testing.T) (*testEnv, seedIDs, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ids := env.seed(t)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
return env, ids, client
|
||||||
|
}
|
||||||
|
|
||||||
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
|
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
|
||||||
func TestLoginAuditRecordsUserID(t *testing.T) {
|
func TestLoginAuditRecordsUserID(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
@@ -85,3 +148,308 @@ func TestChangePasswordRejectsTooLong(t *testing.T) {
|
|||||||
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
|
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导入遇到新证件号 → 新建人员(name/gender/id_card/team)并建报名。
|
||||||
|
func TestImportCreatesAthleteWithIDCard(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||||
|
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
|
||||||
|
}
|
||||||
|
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||||
|
t.Fatalf("不应有冲突: %+v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
name, gender string
|
||||||
|
teamID int64
|
||||||
|
)
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT name, gender, team_id FROM athletes WHERE id_card = ?`, "110101199001011234").
|
||||||
|
Scan(&name, &gender, &teamID); err != nil {
|
||||||
|
t.Fatalf("查询新建人员: %v", err)
|
||||||
|
}
|
||||||
|
if name != "张三" || gender != "男" || teamID != ids.teamA {
|
||||||
|
t.Fatalf("新建人员字段不对: name=%s gender=%s team=%d", name, gender, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||||||
|
ids.event, "110101199001011234").Scan(&entries); err != nil {
|
||||||
|
t.Fatalf("统计报名: %v", err)
|
||||||
|
}
|
||||||
|
if entries != 1 {
|
||||||
|
t.Fatalf("应建 1 条报名,得到 %d", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 姓名 + 单位命中且库中证件号为空 → 回填证件号,不新建人员。
|
||||||
|
func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||||
|
t.Fatalf("回填应更新已有报名: %+v", result)
|
||||||
|
}
|
||||||
|
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||||
|
t.Fatalf("回填不算冲突: %+v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var idCard sql.NullString
|
||||||
|
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
|
||||||
|
t.Fatalf("查询人员证件号: %v", err)
|
||||||
|
}
|
||||||
|
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||||||
|
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
var athletes int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||||
|
t.Fatalf("统计人员: %v", err)
|
||||||
|
}
|
||||||
|
if athletes != 2 {
|
||||||
|
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||||
|
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(0) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||||||
|
t.Fatalf("冲突行不应计入新增/更新/跳过: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts, _ := result["conflicts"].([]any)
|
||||||
|
if len(conflicts) != 1 {
|
||||||
|
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||||
|
}
|
||||||
|
conflict := conflicts[0].(map[string]any)
|
||||||
|
if conflict["row"] != float64(2) || conflict["idCard"] != "110101199001011234" ||
|
||||||
|
conflict["athleteId"] != float64(ids.athleteA) {
|
||||||
|
t.Fatalf("冲突定位信息不对: %+v", conflict)
|
||||||
|
}
|
||||||
|
changed, _ := conflict["changedFields"].([]any)
|
||||||
|
if len(changed) != 1 || changed[0] != "teamCode" {
|
||||||
|
t.Fatalf("应只有 teamCode 变化,得到 %+v", changed)
|
||||||
|
}
|
||||||
|
existing := conflict["existing"].(map[string]any)
|
||||||
|
if existing["name"] != "张伟" || existing["teamCode"] != "A" || existing["gender"] != "男" {
|
||||||
|
t.Fatalf("existing 快照不对: %+v", existing)
|
||||||
|
}
|
||||||
|
incoming := conflict["incoming"].(map[string]any)
|
||||||
|
if incoming["name"] != "张伟" || incoming["teamCode"] != "B" {
|
||||||
|
t.Fatalf("incoming 快照不对: %+v", incoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamID int64
|
||||||
|
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||||||
|
t.Fatalf("查询人员单位: %v", err)
|
||||||
|
}
|
||||||
|
if teamID != ids.teamA {
|
||||||
|
t.Fatalf("冲突不应改人员单位,得到 %d", teamID)
|
||||||
|
}
|
||||||
|
var entries int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||||||
|
t.Fatalf("统计报名: %v", err)
|
||||||
|
}
|
||||||
|
if entries != 0 {
|
||||||
|
t.Fatalf("冲突行的报名不应落库,得到 %d 条", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一文件里证件号重复出现 → 该行报错并跳过。
|
||||||
|
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||||||
|
env, _, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
body := importHeader + "\n" +
|
||||||
|
"M100,男子,A,张三,110101199001011234,男,,,registered,\n" +
|
||||||
|
"M100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(1) || result["skipped"] != float64(1) {
|
||||||
|
t.Fatalf("首行应新建、次行应跳过: %+v", result)
|
||||||
|
}
|
||||||
|
errs, _ := result["errors"].([]any)
|
||||||
|
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
|
||||||
|
t.Fatalf("应报证件号重复,得到 %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var athletes int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||||
|
t.Fatalf("统计人员: %v", err)
|
||||||
|
}
|
||||||
|
if athletes != 1 {
|
||||||
|
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 athlete_name 的行必须给 id_card。
|
||||||
|
func TestImportRequiresIDCardWhenNameGiven(t *testing.T) {
|
||||||
|
env, _, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,A,张三,,男,,,registered,\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||||
|
t.Fatalf("缺证件号应跳过: %+v", result)
|
||||||
|
}
|
||||||
|
errs, _ := result["errors"].([]any)
|
||||||
|
if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") {
|
||||||
|
t.Fatalf("应报缺少 id_card,得到 %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var athletes int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||||
|
t.Fatalf("统计人员: %v", err)
|
||||||
|
}
|
||||||
|
if athletes != 2 {
|
||||||
|
t.Fatalf("该行不应建人员,得到 %d 个", athletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// id_card / gender 是可选列:表头缺失不报错(团体行不涉及人员)。
|
||||||
|
func TestImportToleratesMissingOptionalColumns(t *testing.T) {
|
||||||
|
env, _, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
body := "event_code,category,team_code,athlete_name,bib,seed,status,member_names\n" +
|
||||||
|
"M100,男子,A,,,,,registered,张三/李四\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("缺可选列应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||||
|
t.Fatalf("团体行应正常导入: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
|
||||||
|
func TestImportResolveKeepExisting(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
|
||||||
|
|
||||||
|
// 未给决策时,冲突原样返回。
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("决策接口应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 1 {
|
||||||
|
t.Fatalf("未决策的冲突应继续返回: %+v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
|
||||||
|
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||||
|
t.Fatalf("keep-existing 应更新库里已有报名: %+v", result)
|
||||||
|
}
|
||||||
|
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||||
|
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamID int64
|
||||||
|
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||||||
|
t.Fatalf("查询人员单位: %v", err)
|
||||||
|
}
|
||||||
|
if teamID != ids.teamA {
|
||||||
|
t.Fatalf("keep-existing 应保留库中单位,得到 %d", teamID)
|
||||||
|
}
|
||||||
|
var entries int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||||||
|
t.Fatalf("统计报名: %v", err)
|
||||||
|
}
|
||||||
|
if entries != 0 {
|
||||||
|
t.Fatalf("keep-existing 不应在表里的单位下建报名,得到 %d 条", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
var audit int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
|
||||||
|
t.Fatalf("统计审计: %v", err)
|
||||||
|
}
|
||||||
|
if audit == 0 {
|
||||||
|
t.Fatalf("决策应写审计")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
|
||||||
|
func TestImportResolveKeepNew(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||||
|
|
||||||
|
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
|
||||||
|
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["created"] != float64(1) || result["updated"] != float64(0) {
|
||||||
|
t.Fatalf("keep-new 应在表里的单位下新建报名: %+v", result)
|
||||||
|
}
|
||||||
|
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||||
|
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamID int64
|
||||||
|
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||||||
|
t.Fatalf("查询人员单位: %v", err)
|
||||||
|
}
|
||||||
|
if teamID != ids.teamB {
|
||||||
|
t.Fatalf("keep-new 应用表里的单位更新人员,得到 %d", teamID)
|
||||||
|
}
|
||||||
|
var entries int
|
||||||
|
if err := env.ev.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||||||
|
t.Fatalf("统计报名: %v", err)
|
||||||
|
}
|
||||||
|
if entries != 1 {
|
||||||
|
t.Fatalf("keep-new 应在表里的单位下建 1 条报名,得到 %d 条", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decisions 非法时返回 400。
|
||||||
|
func TestImportResolveRejectsBadDecisions(t *testing.T) {
|
||||||
|
env, _, client := recorderWithSeed(t)
|
||||||
|
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||||
|
|
||||||
|
for _, decisions := range []string{"不是 JSON", `[{"idCard":"1","action":"keep-both"}]`} {
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||||
|
if status != http.StatusBadRequest || env2.Error == nil {
|
||||||
|
t.Fatalf("非法 decisions 应 400,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,23 +287,33 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
|||||||
|
|
||||||
// ---------- athletes ----------
|
// ---------- athletes ----------
|
||||||
|
|
||||||
const athleteColumns = `id, name, gender, team_id, employee_no, active`
|
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active`
|
||||||
|
|
||||||
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
||||||
var (
|
var (
|
||||||
a athleteDTO
|
a athleteDTO
|
||||||
gender, empNo sql.NullString
|
gender, empNo, idCard sql.NullString
|
||||||
teamID sql.NullInt64
|
teamID sql.NullInt64
|
||||||
)
|
)
|
||||||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active)
|
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active)
|
||||||
a.Gender = strPtr(gender)
|
a.Gender = strPtr(gender)
|
||||||
a.EmployeeNo = strPtr(empNo)
|
a.EmployeeNo = strPtr(empNo)
|
||||||
|
a.IDCard = strPtr(idCard)
|
||||||
if teamID.Valid {
|
if teamID.Valid {
|
||||||
a.TeamID = teamID.Int64
|
a.TeamID = teamID.Int64
|
||||||
}
|
}
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
|
||||||
|
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
|
||||||
|
func isIDCardConflict(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
||||||
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
||||||
args := []any{}
|
args := []any{}
|
||||||
@@ -345,9 +355,22 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
|||||||
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
||||||
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
||||||
}
|
}
|
||||||
|
// 证件号是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||||||
|
if in.IDCard != nil && *in.IDCard != "" {
|
||||||
|
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? LIMIT 1`, *in.IDCard)
|
||||||
|
if err != nil {
|
||||||
|
return athleteDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||||
|
}
|
||||||
|
}
|
||||||
res, err := s.ev.ExecContext(ctx,
|
res, err := s.ev.ExecContext(ctx,
|
||||||
`INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, active) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active)
|
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active)
|
||||||
|
if isIDCardConflict(err) {
|
||||||
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||||
|
}
|
||||||
if isForeignKeyViolation(err) {
|
if isForeignKeyViolation(err) {
|
||||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||||
}
|
}
|
||||||
@@ -362,9 +385,21 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
||||||
|
if in.IDCard != nil && *in.IDCard != "" {
|
||||||
|
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id)
|
||||||
|
if err != nil {
|
||||||
|
return athleteDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||||
|
}
|
||||||
|
}
|
||||||
res, err := s.ev.ExecContext(ctx,
|
res, err := s.ev.ExecContext(ctx,
|
||||||
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`,
|
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, active = ? WHERE id = ?`,
|
||||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id)
|
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active, id)
|
||||||
|
if isIDCardConflict(err) {
|
||||||
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||||
|
}
|
||||||
if isForeignKeyViolation(err) {
|
if isForeignKeyViolation(err) {
|
||||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,171 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64
|
|||||||
return id, nil
|
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 表示新建。
|
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
|
||||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
|
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
|
||||||
var existing int64
|
var existing int64
|
||||||
|
|||||||
@@ -85,3 +85,58 @@ func TestEventWritesWriteChangeLog(t *testing.T) {
|
|||||||
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,6 +211,7 @@ func (s *server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
|
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||||
|
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||||
|
|
||||||
// 成绩
|
// 成绩
|
||||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 证件号是人员的唯一标识(重名靠它区分)。历史数据没有证件号,允许为空;
|
||||||
|
-- 用部分唯一索引只约束「填了证件号」的行,空值可以有多条。
|
||||||
|
ALTER TABLE athletes ADD COLUMN id_card TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_athletes_id_card
|
||||||
|
ON athletes(id_card) WHERE id_card IS NOT NULL AND id_card <> '';
|
||||||
@@ -16,6 +16,7 @@ interface AthleteForm {
|
|||||||
gender: '' | Gender
|
gender: '' | Gender
|
||||||
teamId: string
|
teamId: string
|
||||||
employeeNo: string
|
employeeNo: string
|
||||||
|
idCard: string
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ const confirmTarget = ref<Athlete | null>(null)
|
|||||||
const confirmPending = ref(false)
|
const confirmPending = ref(false)
|
||||||
|
|
||||||
function emptyForm(): AthleteForm {
|
function emptyForm(): AthleteForm {
|
||||||
return { name: '', gender: '', teamId: '', employeeNo: '', active: true }
|
return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
@@ -51,6 +52,7 @@ const filtered = computed(() => {
|
|||||||
return (
|
return (
|
||||||
athlete.name.toLowerCase().includes(keyword) ||
|
athlete.name.toLowerCase().includes(keyword) ||
|
||||||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
|
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
|
||||||
|
(athlete.idCard ?? '').toLowerCase().includes(keyword) ||
|
||||||
(team?.name ?? '').toLowerCase().includes(keyword)
|
(team?.name ?? '').toLowerCase().includes(keyword)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -74,6 +76,7 @@ function openEdit(athlete: Athlete): void {
|
|||||||
gender: athlete.gender ?? '',
|
gender: athlete.gender ?? '',
|
||||||
teamId: String(athlete.teamId),
|
teamId: String(athlete.teamId),
|
||||||
employeeNo: athlete.employeeNo ?? '',
|
employeeNo: athlete.employeeNo ?? '',
|
||||||
|
idCard: athlete.idCard ?? '',
|
||||||
active: athlete.active,
|
active: athlete.active,
|
||||||
}
|
}
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
@@ -100,6 +103,7 @@ async function submit(): Promise<void> {
|
|||||||
gender: form.value.gender === '' ? null : form.value.gender,
|
gender: form.value.gender === '' ? null : form.value.gender,
|
||||||
teamId: Number(form.value.teamId),
|
teamId: Number(form.value.teamId),
|
||||||
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
|
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
|
||||||
|
idCard: form.value.idCard.trim() === '' ? null : form.value.idCard.trim(),
|
||||||
active: form.value.active,
|
active: form.value.active,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -131,6 +135,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
gender: athlete.gender,
|
gender: athlete.gender,
|
||||||
teamId: athlete.teamId,
|
teamId: athlete.teamId,
|
||||||
employeeNo: athlete.employeeNo,
|
employeeNo: athlete.employeeNo,
|
||||||
|
idCard: athlete.idCard,
|
||||||
active: !athlete.active,
|
active: !athlete.active,
|
||||||
},
|
},
|
||||||
athlete.id,
|
athlete.id,
|
||||||
@@ -157,7 +162,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
<div class="bd-toolbar-left">
|
<div class="bd-toolbar-left">
|
||||||
<div class="mx-input-wrap bd-search">
|
<div class="mx-input-wrap bd-search">
|
||||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
|
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号" />
|
||||||
</div>
|
</div>
|
||||||
<select v-model="teamFilter" class="mx-cell-input bd-select">
|
<select v-model="teamFilter" class="mx-cell-input bd-select">
|
||||||
<option value="all">全部单位</option>
|
<option value="all">全部单位</option>
|
||||||
@@ -184,13 +189,14 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
<th class="bd-cell-mini">性别</th>
|
<th class="bd-cell-mini">性别</th>
|
||||||
<th>所属单位</th>
|
<th>所属单位</th>
|
||||||
<th>工号 / 学号</th>
|
<th>工号 / 学号</th>
|
||||||
|
<th>证件号</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th class="right">操作</th>
|
<th class="right">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
||||||
<td colspan="6">
|
<td colspan="7">
|
||||||
<div class="mx-empty bd-empty">
|
<div class="mx-empty bd-empty">
|
||||||
<span class="mx-spinner" />
|
<span class="mx-spinner" />
|
||||||
<div class="mx-empty-title">加载中…</div>
|
<div class="mx-empty-title">加载中…</div>
|
||||||
@@ -203,6 +209,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
<td>{{ athlete.gender ?? '—' }}</td>
|
<td>{{ athlete.gender ?? '—' }}</td>
|
||||||
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
|
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
|
||||||
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
|
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
|
||||||
|
<td class="bd-mono">{{ athlete.idCard ?? '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
{{ athlete.active ? '启用' : '已停用' }}
|
{{ athlete.active ? '启用' : '已停用' }}
|
||||||
@@ -226,7 +233,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="filtered.length === 0">
|
<tr v-if="filtered.length === 0">
|
||||||
<td colspan="6">
|
<td colspan="7">
|
||||||
<div class="mx-empty bd-empty">
|
<div class="mx-empty bd-empty">
|
||||||
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
|
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
|
||||||
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
|
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
|
||||||
@@ -271,6 +278,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
|||||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="证件号" hint="人员唯一标识,建议必填">
|
||||||
|
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
||||||
|
</FormField>
|
||||||
<FormField label="状态" wide>
|
<FormField label="状态" wide>
|
||||||
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
|
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
|
|||||||
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
|
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
|
||||||
|
function missingIdCard(athlete: Athlete): boolean {
|
||||||
|
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEntries(id: number): Promise<void> {
|
async function loadEntries(id: number): Promise<void> {
|
||||||
entriesLoading.value = true
|
entriesLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +138,7 @@ async function submit(): Promise<void> {
|
|||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
teamCode: team.code,
|
teamCode: team.code,
|
||||||
mode: mode.value,
|
mode: mode.value,
|
||||||
athletes: chosen.map((athlete) => ({ name: athlete.name })),
|
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
|
||||||
})
|
})
|
||||||
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
||||||
const result = await data.importEntries(file)
|
const result = await data.importEntries(file)
|
||||||
@@ -222,11 +227,12 @@ async function submit(): Promise<void> {
|
|||||||
v-model="checked"
|
v-model="checked"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="athlete.id"
|
:value="athlete.id"
|
||||||
:disabled="!data.canWrite.value || isRegistered(athlete)"
|
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)"
|
||||||
/>
|
/>
|
||||||
<span class="picker-name">{{ athlete.name }}</span>
|
<span class="picker-name">{{ athlete.name }}</span>
|
||||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
|
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
|
||||||
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||||
|
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
|
||||||
</label>
|
</label>
|
||||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||||
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||||
@@ -238,6 +244,7 @@ async function submit(): Promise<void> {
|
|||||||
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
||||||
</p>
|
</p>
|
||||||
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
|
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
|
||||||
|
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
|
||||||
|
|
||||||
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { EntryImportResult } from '@/shared'
|
import type { ConflictAction, EntryImportResult, ImportConflict } from '@/shared'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import EntryPicker from './EntryPicker.vue'
|
import EntryPicker from './EntryPicker.vue'
|
||||||
import { useBaseData } from './useBaseData'
|
import { useBaseData } from './useBaseData'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
import { REQUIRED_COLUMNS } from './entrySelection'
|
import {
|
||||||
|
REQUIRED_COLUMNS,
|
||||||
|
conflictDiffs,
|
||||||
|
decisionsPayload,
|
||||||
|
pruneDecisions,
|
||||||
|
recordDecisions,
|
||||||
|
summarizeDecisions,
|
||||||
|
type DecisionMap,
|
||||||
|
} from './entrySelection'
|
||||||
|
|
||||||
const data = useBaseData()
|
const data = useBaseData()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const TEMPLATE_CSV = [
|
const TEMPLATE_CSV = [
|
||||||
REQUIRED_COLUMNS.join(','),
|
REQUIRED_COLUMNS.join(','),
|
||||||
'M100,男子组,F1,张伟,101,,registered,',
|
'M100,男子组,F1,张伟,110101199001011234,男,101,,registered,',
|
||||||
'BB,混合组,F2,,,种子1,registered,第一分公司代表队',
|
'BB,混合组,F2,,,,,种子1,registered,第一分公司代表队',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const MAX_SIZE = 16 * 1024 * 1024
|
const MAX_SIZE = 16 * 1024 * 1024
|
||||||
@@ -25,6 +33,19 @@ const importing = ref(false)
|
|||||||
const result = ref<EntryImportResult | null>(null)
|
const result = ref<EntryImportResult | null>(null)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 冲突决策面板的暂存状态:证件号 → 保留哪一侧,点「应用决策」才提交。 */
|
||||||
|
const decisions = ref<DecisionMap>({})
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
const resolving = ref(false)
|
||||||
|
const resolveError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const conflicts = computed<ImportConflict[]>(() => result.value?.conflicts ?? [])
|
||||||
|
const summary = computed(() => summarizeDecisions(decisions.value, conflicts.value))
|
||||||
|
const pendingCount = computed(() => summary.value.keepExisting + summary.value.keepNew)
|
||||||
|
const canResolve = computed(
|
||||||
|
() => data.canWrite.value && pendingCount.value > 0 && !resolving.value && picked.value !== null,
|
||||||
|
)
|
||||||
|
|
||||||
const fileLabel = computed(() => (picked.value === null ? '' : `${picked.value.name} · ${formatSize(picked.value.size)}`))
|
const fileLabel = computed(() => (picked.value === null ? '' : `${picked.value.name} · ${formatSize(picked.value.size)}`))
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -50,6 +71,59 @@ function acceptFile(file: File): void {
|
|||||||
picked.value = file
|
picked.value = file
|
||||||
result.value = null
|
result.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
resetDecisions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDecisions(): void {
|
||||||
|
decisions.value = {}
|
||||||
|
selected.value = []
|
||||||
|
resolveError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFile(): void {
|
||||||
|
picked.value = null
|
||||||
|
result.value = null
|
||||||
|
error.value = null
|
||||||
|
resetDecisions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll(): void {
|
||||||
|
selected.value = conflicts.value.map((conflict) => conflict.idCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
selected.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量决策只作用于勾选行;逐行按钮则直接改单条。 */
|
||||||
|
function decideSelected(action: ConflictAction): void {
|
||||||
|
decisions.value = recordDecisions(decisions.value, conflicts.value, selected.value, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decideOne(conflict: ImportConflict, action: ConflictAction): void {
|
||||||
|
decisions.value = recordDecisions(decisions.value, conflicts.value, [conflict.idCard], action)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDecisions(): Promise<void> {
|
||||||
|
const file = picked.value
|
||||||
|
if (file === null) return
|
||||||
|
const payload = decisionsPayload(decisions.value, conflicts.value)
|
||||||
|
if (payload.length === 0) return
|
||||||
|
resolving.value = true
|
||||||
|
resolveError.value = null
|
||||||
|
try {
|
||||||
|
const next = await data.resolveImportConflicts(file, payload)
|
||||||
|
result.value = next
|
||||||
|
// 已决策的人不再出现在剩余冲突里,同步清掉暂存与勾选,避免串到下一轮。
|
||||||
|
decisions.value = pruneDecisions(decisions.value, next.conflicts)
|
||||||
|
const remaining = new Set(next.conflicts.map((conflict) => conflict.idCard))
|
||||||
|
selected.value = selected.value.filter((idCard) => remaining.has(idCard))
|
||||||
|
toast.success('冲突决策已应用', `新增 ${next.created} · 更新 ${next.updated} · 跳过 ${next.skipped}`)
|
||||||
|
} catch (caught) {
|
||||||
|
resolveError.value = caught instanceof Error ? caught.message : '应用决策失败'
|
||||||
|
} finally {
|
||||||
|
resolving.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(event: Event): void {
|
function onPick(event: Event): void {
|
||||||
@@ -72,6 +146,7 @@ async function runImport(): Promise<void> {
|
|||||||
importing.value = true
|
importing.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
result.value = null
|
result.value = null
|
||||||
|
resetDecisions()
|
||||||
try {
|
try {
|
||||||
result.value = await data.importEntries(picked.value)
|
result.value = await data.importEntries(picked.value)
|
||||||
toast.success('导入完成', `新增 ${result.value.created} · 更新 ${result.value.updated} · 跳过 ${result.value.skipped}`)
|
toast.success('导入完成', `新增 ${result.value.created} · 更新 ${result.value.updated} · 跳过 ${result.value.skipped}`)
|
||||||
@@ -111,8 +186,9 @@ function downloadTemplate(): void {
|
|||||||
<div class="bd-warn">
|
<div class="bd-warn">
|
||||||
<MxIcon name="warning" :size="14" />
|
<MxIcon name="warning" :size="14" />
|
||||||
<span>
|
<span>
|
||||||
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
|
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender 可缺省,
|
||||||
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
|
但有姓名的行必须填证件号,否则该行会报错并跳过。证件号是人员唯一标识:先按证件号匹配,
|
||||||
|
未命中再按「姓名 + 单位」匹配库里证件号为空的人并回填。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +209,7 @@ function downloadTemplate(): void {
|
|||||||
<div v-if="picked !== null" class="picked">
|
<div v-if="picked !== null" class="picked">
|
||||||
<span class="bd-mono">{{ fileLabel }}</span>
|
<span class="bd-mono">{{ fileLabel }}</span>
|
||||||
<div class="bd-toolbar-right">
|
<div class="bd-toolbar-right">
|
||||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="clearFile">移除</button>
|
||||||
<button
|
<button
|
||||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -176,7 +252,102 @@ function downloadTemplate(): void {
|
|||||||
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
|
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
|
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
|
||||||
|
|
||||||
|
<section v-if="conflicts.length > 0" class="conflicts">
|
||||||
|
<div class="conflicts-head">
|
||||||
|
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
||||||
|
<span class="bd-note">
|
||||||
|
同一证件号在库里与报名表里的姓名 / 性别 / 单位不一致,本轮未落库,也不建报名。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
|
||||||
|
|
||||||
|
<div class="bd-toolbar conflict-toolbar">
|
||||||
|
<div class="bd-toolbar-left">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="selectAll">全选</button>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="selected.length === 0" @click="clearSelection">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<span class="bd-note">已选 {{ selected.length }} / {{ conflicts.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd-toolbar-right">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="selected.length === 0"
|
||||||
|
@click="decideSelected('keep-existing')"
|
||||||
|
>
|
||||||
|
保留已有数据
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="selected.length === 0"
|
||||||
|
@click="decideSelected('keep-new')"
|
||||||
|
>
|
||||||
|
使用新数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conflict-list">
|
||||||
|
<div
|
||||||
|
v-for="conflict in conflicts"
|
||||||
|
:key="conflict.idCard"
|
||||||
|
class="conflict-item"
|
||||||
|
:class="{ decided: decisions[conflict.idCard] !== undefined }"
|
||||||
|
>
|
||||||
|
<div class="conflict-head">
|
||||||
|
<label class="bd-check conflict-check">
|
||||||
|
<input v-model="selected" type="checkbox" :value="conflict.idCard" />
|
||||||
|
<span class="conflict-id bd-mono">{{ conflict.idCard }}</span>
|
||||||
|
<span class="conflict-name">{{ conflict.existing.name }}</span>
|
||||||
|
<span class="bd-note">第 {{ conflict.row }} 行</span>
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
v-if="decisions[conflict.idCard] !== undefined"
|
||||||
|
class="mx-badge"
|
||||||
|
:class="decisions[conflict.idCard] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
|
||||||
|
>
|
||||||
|
{{ decisions[conflict.idCard] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
|
||||||
|
</span>
|
||||||
|
<div class="bd-actions conflict-actions">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-existing')">
|
||||||
|
保留已有
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-new')">
|
||||||
|
使用新数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conflict-diffs">
|
||||||
|
<div
|
||||||
|
v-for="diff in conflictDiffs(conflict)"
|
||||||
|
:key="diff.field"
|
||||||
|
class="conflict-diff"
|
||||||
|
:class="{ changed: diff.changed }"
|
||||||
|
>
|
||||||
|
<span class="conflict-field">{{ diff.label }}</span>
|
||||||
|
<span class="conflict-value">{{ diff.existing }}</span>
|
||||||
|
<span class="conflict-arrow">→</span>
|
||||||
|
<span class="conflict-value">{{ diff.incoming }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conflict-foot">
|
||||||
|
<span class="bd-note">
|
||||||
|
已决策 {{ pendingCount }} 条(保留已有 {{ summary.keepExisting }} · 使用新数据 {{ summary.keepNew }}),
|
||||||
|
未决策 {{ summary.undecided }} 条
|
||||||
|
</span>
|
||||||
|
<button class="mx-btn mx-btn-primary mx-btn-sm" type="button" :disabled="!canResolve" @click="applyDecisions">
|
||||||
|
{{ resolving ? '应用中…' : '应用决策' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="resolveError !== null" class="mx-badge mx-badge-error import-error">{{ resolveError }}</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -281,9 +452,104 @@ function downloadTemplate(): void {
|
|||||||
color: var(--mx-t2);
|
color: var(--mx-t2);
|
||||||
font-family: var(--mx-mono);
|
font-family: var(--mx-mono);
|
||||||
}
|
}
|
||||||
|
.conflicts {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--mx-divider);
|
||||||
|
}
|
||||||
|
.conflicts-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.conflict-toolbar {
|
||||||
|
margin: 12px 0 10px;
|
||||||
|
}
|
||||||
|
.conflict-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.conflict-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s2);
|
||||||
|
}
|
||||||
|
.conflict-item.decided {
|
||||||
|
border-color: var(--mx-primary);
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
}
|
||||||
|
.conflict-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.conflict-check {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.conflict-id {
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.conflict-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-text);
|
||||||
|
}
|
||||||
|
.conflict-head .conflict-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.conflict-diffs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.conflict-diff {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 84px minmax(0, 1fr) 20px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.conflict-field {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.conflict-value {
|
||||||
|
color: var(--mx-t2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.conflict-diff.changed .conflict-value {
|
||||||
|
color: var(--mx-amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.conflict-arrow {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.conflict-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.result-cards {
|
.result-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.conflict-diff {
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr) 16px minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
EventCategory,
|
EventCategory,
|
||||||
EventCategoryInput,
|
EventCategoryInput,
|
||||||
EventInput,
|
EventInput,
|
||||||
|
ImportConflictDecision,
|
||||||
ScoringProfile,
|
ScoringProfile,
|
||||||
ScoringProfileInput,
|
ScoringProfileInput,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
@@ -128,5 +129,10 @@ export const baseDataApi = {
|
|||||||
() => adminApi.client.entries.importFile(file),
|
() => adminApi.client.entries.importFile(file),
|
||||||
() => mockBaseData.importEntries(file),
|
() => mockBaseData.importEntries(file),
|
||||||
),
|
),
|
||||||
|
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> =>
|
||||||
|
call(
|
||||||
|
() => adminApi.client.entries.resolveImport(file, decisions),
|
||||||
|
() => mockBaseData.resolveImportConflicts(file, decisions),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* 「从已有名单勾选报名」的纯函数部分。
|
* 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。
|
||||||
*
|
*
|
||||||
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
|
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
|
||||||
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
|
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
|
||||||
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
|
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
|
import type {
|
||||||
|
ConflictAction,
|
||||||
|
ImportConflict,
|
||||||
|
ImportConflictDecision,
|
||||||
|
ImportConflictField,
|
||||||
|
} from '@/shared'
|
||||||
|
|
||||||
|
/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */
|
||||||
export const REQUIRED_COLUMNS = [
|
export const REQUIRED_COLUMNS = [
|
||||||
'event_code',
|
'event_code',
|
||||||
'category',
|
'category',
|
||||||
'team_code',
|
'team_code',
|
||||||
'athlete_name',
|
'athlete_name',
|
||||||
|
'id_card',
|
||||||
|
'gender',
|
||||||
'bib',
|
'bib',
|
||||||
'seed',
|
'seed',
|
||||||
'status',
|
'status',
|
||||||
'member_names',
|
'member_names',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card。 */
|
||||||
|
export const OPTIONAL_COLUMNS = ['id_card', 'gender'] as const
|
||||||
|
|
||||||
|
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
|
||||||
|
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
|
||||||
|
export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
|
||||||
|
(column) => !OPTIONAL_SET.includes(column),
|
||||||
|
)
|
||||||
|
|
||||||
export type SelectionMode = 'individual' | 'team'
|
export type SelectionMode = 'individual' | 'team'
|
||||||
|
|
||||||
export interface SelectionInput {
|
export interface SelectionInput {
|
||||||
@@ -26,7 +44,8 @@ export interface SelectionInput {
|
|||||||
teamCode: string
|
teamCode: string
|
||||||
status?: string
|
status?: string
|
||||||
mode: SelectionMode
|
mode: SelectionMode
|
||||||
athletes: { name: string }[]
|
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
|
||||||
|
athletes: { name: string; idCard?: string | null }[]
|
||||||
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
|
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
|
||||||
memberNames?: string[]
|
memberNames?: string[]
|
||||||
}
|
}
|
||||||
@@ -42,12 +61,23 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
|||||||
const rows: string[][] = [[...REQUIRED_COLUMNS]]
|
const rows: string[][] = [[...REQUIRED_COLUMNS]]
|
||||||
if (input.mode === 'individual') {
|
if (input.mode === 'individual') {
|
||||||
for (const athlete of input.athletes) {
|
for (const athlete of input.athletes) {
|
||||||
rows.push([input.eventCode, input.categoryName, input.teamCode, athlete.name, '', '', status, ''])
|
rows.push([
|
||||||
|
input.eventCode,
|
||||||
|
input.categoryName,
|
||||||
|
input.teamCode,
|
||||||
|
athlete.name,
|
||||||
|
athlete.idCard ?? '',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
status,
|
||||||
|
'',
|
||||||
|
])
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
|
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
|
||||||
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', status, names.join('/')])
|
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', '', '', status, names.join('/')])
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +90,91 @@ export function toCsv(rows: string[][]): string {
|
|||||||
function escapeField(field: string): string {
|
function escapeField(field: string): string {
|
||||||
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
|
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 导入冲突:差异计算与决策 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
|
||||||
|
name: '姓名',
|
||||||
|
gender: '性别',
|
||||||
|
teamCode: '单位',
|
||||||
|
employeeNo: '工号 / 学号',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
|
||||||
|
export interface ConflictFieldDiff {
|
||||||
|
field: ImportConflictField
|
||||||
|
label: string
|
||||||
|
existing: string
|
||||||
|
incoming: string
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐字段展开 existing / incoming;顺序固定,界面与测试都不依赖对象键序。 */
|
||||||
|
export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
|
||||||
|
return (Object.keys(CONFLICT_FIELD_LABELS) as ImportConflictField[]).map((field) => ({
|
||||||
|
field,
|
||||||
|
label: CONFLICT_FIELD_LABELS[field],
|
||||||
|
existing: formatFieldValue(conflict.existing[field]),
|
||||||
|
incoming: formatFieldValue(conflict.incoming[field]),
|
||||||
|
changed: conflict.changedFields.includes(field),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldValue(value: string | null): string {
|
||||||
|
return value === null || value === '' ? '—' : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 证件号 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||||
|
export type DecisionMap = Record<string, ConflictAction>
|
||||||
|
|
||||||
|
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */
|
||||||
|
export function recordDecisions(
|
||||||
|
current: DecisionMap,
|
||||||
|
conflicts: readonly ImportConflict[],
|
||||||
|
idCards: readonly string[],
|
||||||
|
action: ConflictAction,
|
||||||
|
): DecisionMap {
|
||||||
|
const known = new Set(conflicts.map((conflict) => conflict.idCard))
|
||||||
|
const next: DecisionMap = { ...current }
|
||||||
|
for (const idCard of idCards) {
|
||||||
|
if (known.has(idCard)) next[idCard] = action
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 丢弃已不存在的证件号,换文件或 resolve 之后调用。 */
|
||||||
|
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
|
||||||
|
const known = new Set(conflicts.map((conflict) => conflict.idCard))
|
||||||
|
return Object.fromEntries(Object.entries(current).filter(([idCard]) => known.has(idCard)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
|
||||||
|
export function decisionsPayload(
|
||||||
|
current: DecisionMap,
|
||||||
|
conflicts: readonly ImportConflict[],
|
||||||
|
): ImportConflictDecision[] {
|
||||||
|
return conflicts
|
||||||
|
.filter((conflict) => current[conflict.idCard] !== undefined)
|
||||||
|
.map((conflict) => ({ idCard: conflict.idCard, action: current[conflict.idCard] as ConflictAction }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecisionSummary {
|
||||||
|
keepExisting: number
|
||||||
|
keepNew: number
|
||||||
|
undecided: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面板工具条上的计数:已决策分组 + 未决策数,未决策的冲突必须保持可见。 */
|
||||||
|
export function summarizeDecisions(
|
||||||
|
current: DecisionMap,
|
||||||
|
conflicts: readonly ImportConflict[],
|
||||||
|
): DecisionSummary {
|
||||||
|
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
const action = current[conflict.idCard]
|
||||||
|
if (action === 'keep-existing') summary.keepExisting += 1
|
||||||
|
else if (action === 'keep-new') summary.keepNew += 1
|
||||||
|
else summary.undecided += 1
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
Athlete,
|
Athlete,
|
||||||
AthleteInput,
|
AthleteInput,
|
||||||
|
ConflictAction,
|
||||||
EntryImportResult,
|
EntryImportResult,
|
||||||
Event,
|
Event,
|
||||||
EventCategory,
|
EventCategory,
|
||||||
EventCategoryInput,
|
EventCategoryInput,
|
||||||
EventInput,
|
EventInput,
|
||||||
|
ImportConflict,
|
||||||
|
ImportConflictDecision,
|
||||||
|
ImportConflictField,
|
||||||
ScoringProfile,
|
ScoringProfile,
|
||||||
ScoringProfileInput,
|
ScoringProfileInput,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
@@ -15,7 +19,7 @@ import type {
|
|||||||
Team,
|
Team,
|
||||||
TeamInput,
|
TeamInput,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
import { REQUIRED_COLUMNS } from './entrySelection'
|
import { MANDATORY_COLUMNS } from './entrySelection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础数据页的离线示例数据源。
|
* 基础数据页的离线示例数据源。
|
||||||
@@ -172,11 +176,12 @@ const ATHLETE_NAMES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/** 报名表模板列,与导入通道共用同一份定义(大小写不敏感)。 */
|
/** 报名表模板列,与导入通道共用同一份定义(大小写不敏感)。 */
|
||||||
const IMPORT_COLUMNS = REQUIRED_COLUMNS
|
const IMPORT_COLUMNS = MANDATORY_COLUMNS
|
||||||
|
|
||||||
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
||||||
|
|
||||||
class MockBaseData {
|
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
|
||||||
|
export class MockBaseData {
|
||||||
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
|
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
|
||||||
private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport }))
|
private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport }))
|
||||||
private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({
|
private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({
|
||||||
@@ -231,6 +236,7 @@ class MockBaseData {
|
|||||||
gender: nameIndex % 2 === 0 ? '男' : '女',
|
gender: nameIndex % 2 === 0 ? '男' : '女',
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
|
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
|
||||||
|
idCard: null,
|
||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -425,10 +431,18 @@ class MockBaseData {
|
|||||||
// ── 报名导入 ──────────────────────────────────────────────────────────────
|
// ── 报名导入 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async importEntries(file: File): Promise<EntryImportResult> {
|
async importEntries(file: File): Promise<EntryImportResult> {
|
||||||
|
return this.runImport(file, new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冲突决策重跑:decisions 为空的证件号仍视为未决策,继续留在返回的 conflicts 里。 */
|
||||||
|
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
|
||||||
|
return this.runImport(file, new Map(decisions.map((decision) => [decision.idCard, decision.action])))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
|
||||||
|
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [] })
|
||||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||||
if (extension !== '.csv') {
|
if (extension !== '.csv') return empty([XLSX_HINT])
|
||||||
return { created: 0, updated: 0, skipped: 0, errors: [XLSX_HINT] }
|
|
||||||
}
|
|
||||||
const rows = parseCsv(await file.text())
|
const rows = parseCsv(await file.text())
|
||||||
if (rows.length < 2) throw new Error('报名表为空')
|
if (rows.length < 2) throw new Error('报名表为空')
|
||||||
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
|
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
|
||||||
@@ -437,7 +451,8 @@ class MockBaseData {
|
|||||||
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [] }
|
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
|
||||||
|
const seenIdCards = new Set<string>()
|
||||||
rows.slice(1).forEach((row, offset) => {
|
rows.slice(1).forEach((row, offset) => {
|
||||||
const line = offset + 2
|
const line = offset + 2
|
||||||
if (row.every((cell) => cell.trim() === '')) return
|
if (row.every((cell) => cell.trim() === '')) return
|
||||||
@@ -448,6 +463,7 @@ class MockBaseData {
|
|||||||
const eventCode = cell('event_code')
|
const eventCode = cell('event_code')
|
||||||
const teamCode = cell('team_code')
|
const teamCode = cell('team_code')
|
||||||
const categoryName = cell('category')
|
const categoryName = cell('category')
|
||||||
|
const athleteName = cell('athlete_name')
|
||||||
if (eventCode === '' || teamCode === '') {
|
if (eventCode === '' || teamCode === '') {
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
result.errors.push(`第 ${line} 行:event_code / team_code 必填`)
|
result.errors.push(`第 ${line} 行:event_code / team_code 必填`)
|
||||||
@@ -476,10 +492,94 @@ class MockBaseData {
|
|||||||
result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`)
|
result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 团体 / 对抗行没有姓名,行为不变。
|
||||||
|
if (athleteName === '') {
|
||||||
|
result.created += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idCard = cell('id_card')
|
||||||
|
if (idCard === '') {
|
||||||
|
result.skipped += 1
|
||||||
|
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (seenIdCards.has(idCard)) {
|
||||||
|
result.skipped += 1
|
||||||
|
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenIdCards.add(idCard)
|
||||||
|
const rawGender = cell('gender')
|
||||||
|
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
|
||||||
|
const matched = this.athletes.find((athlete) => athlete.idCard === idCard)
|
||||||
|
if (matched !== undefined) {
|
||||||
|
const conflict = this.detectConflict(matched, line, idCard, athleteName, gender, team)
|
||||||
|
if (conflict !== null && decisions.get(idCard) === undefined) {
|
||||||
|
result.conflicts.push(conflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (conflict !== null && decisions.get(idCard) === 'keep-new') {
|
||||||
|
matched.name = athleteName
|
||||||
|
if (gender !== null) matched.gender = gender
|
||||||
|
matched.teamId = team.id
|
||||||
|
}
|
||||||
|
result.created += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 证件号未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
|
||||||
|
const legacy = this.athletes.find(
|
||||||
|
(athlete) =>
|
||||||
|
athlete.name === athleteName &&
|
||||||
|
athlete.teamId === team.id &&
|
||||||
|
(athlete.idCard === null || athlete.idCard === ''),
|
||||||
|
)
|
||||||
|
if (legacy !== undefined) {
|
||||||
|
legacy.idCard = idCard
|
||||||
|
result.created += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.athletes.push({
|
||||||
|
id: (this.nextAthleteId += 1),
|
||||||
|
name: athleteName,
|
||||||
|
gender,
|
||||||
|
teamId: team.id,
|
||||||
|
employeeNo: null,
|
||||||
|
idCard,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
result.created += 1
|
result.created += 1
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** name / gender / teamCode 与库里不一致才算冲突;表里 gender 为空视为未指定。 */
|
||||||
|
private detectConflict(
|
||||||
|
athlete: Athlete,
|
||||||
|
row: number,
|
||||||
|
idCard: string,
|
||||||
|
name: string,
|
||||||
|
gender: Athlete['gender'],
|
||||||
|
team: Team,
|
||||||
|
): ImportConflict | null {
|
||||||
|
const changed: ImportConflictField[] = []
|
||||||
|
if (athlete.name !== name) changed.push('name')
|
||||||
|
if (gender !== null && athlete.gender !== gender) changed.push('gender')
|
||||||
|
if (athlete.teamId !== team.id) changed.push('teamCode')
|
||||||
|
if (changed.length === 0) return null
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
idCard,
|
||||||
|
athleteId: athlete.id,
|
||||||
|
existing: {
|
||||||
|
name: athlete.name,
|
||||||
|
gender: athlete.gender,
|
||||||
|
teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '',
|
||||||
|
employeeNo: athlete.employeeNo,
|
||||||
|
},
|
||||||
|
incoming: { name, gender, teamCode: team.code, employeeNo: null },
|
||||||
|
changedFields: changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
|
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
EventCategory,
|
EventCategory,
|
||||||
EventCategoryInput,
|
EventCategoryInput,
|
||||||
EventInput,
|
EventInput,
|
||||||
|
ImportConflictDecision,
|
||||||
ScoringProfile,
|
ScoringProfile,
|
||||||
ScoringProfileInput,
|
ScoringProfileInput,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
@@ -235,6 +236,10 @@ function importEntries(file: File): Promise<EntryImportResult> {
|
|||||||
return baseDataApi.entries.importFile(file)
|
return baseDataApi.entries.importFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
|
||||||
|
return baseDataApi.entries.resolveImportConflicts(file, decisions)
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseDataController {
|
export interface BaseDataController {
|
||||||
teams: Ref<Team[]>
|
teams: Ref<Team[]>
|
||||||
sports: Ref<Sport[]>
|
sports: Ref<Sport[]>
|
||||||
@@ -268,6 +273,7 @@ export interface BaseDataController {
|
|||||||
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
|
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
|
||||||
removeAthlete: (id: number) => Promise<void>
|
removeAthlete: (id: number) => Promise<void>
|
||||||
importEntries: (file: File) => Promise<EntryImportResult>
|
importEntries: (file: File) => Promise<EntryImportResult>
|
||||||
|
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBaseData(): BaseDataController {
|
export function useBaseData(): BaseDataController {
|
||||||
@@ -309,5 +315,6 @@ export function useBaseData(): BaseDataController {
|
|||||||
saveAthlete,
|
saveAthlete,
|
||||||
removeAthlete,
|
removeAthlete,
|
||||||
importEntries,
|
importEntries,
|
||||||
|
resolveImportConflicts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { REQUIRED_COLUMNS, buildSelectionRows, toCsv } from '../components/baseData/entrySelection'
|
import {
|
||||||
|
MANDATORY_COLUMNS,
|
||||||
|
OPTIONAL_COLUMNS,
|
||||||
|
REQUIRED_COLUMNS,
|
||||||
|
buildSelectionRows,
|
||||||
|
toCsv,
|
||||||
|
} from '../components/baseData/entrySelection'
|
||||||
|
|
||||||
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
||||||
assert.equal(toCsv([['a', 'b']]), 'a,b')
|
assert.equal(toCsv([['a', 'b']]), 'a,b')
|
||||||
@@ -11,19 +17,33 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
|||||||
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildSelectionRows 个人项目:一人一行,共 8 列', () => {
|
test('表头含证件号 / 性别可选列,必填列不含这两列', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[...REQUIRED_COLUMNS],
|
||||||
|
['event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'bib', 'seed', 'status', 'member_names'],
|
||||||
|
)
|
||||||
|
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender'])
|
||||||
|
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||||
|
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildSelectionRows 个人项目:一人一行,共 10 列并带上证件号', () => {
|
||||||
const rows = buildSelectionRows({
|
const rows = buildSelectionRows({
|
||||||
eventCode: 'M100',
|
eventCode: 'M100',
|
||||||
categoryName: '男子组',
|
categoryName: '男子组',
|
||||||
teamCode: 'F1',
|
teamCode: 'F1',
|
||||||
mode: 'individual',
|
mode: 'individual',
|
||||||
athletes: [{ name: '张伟' }, { name: '李强' }],
|
athletes: [
|
||||||
|
{ name: '张伟', idCard: '110101199001011234' },
|
||||||
|
{ name: '李强', idCard: null },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
assert.equal(rows.length, 3, '表头 + 2 名运动员')
|
assert.equal(rows.length, 3, '表头 + 2 名运动员')
|
||||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||||
assert.equal(rows[1]?.length, 8)
|
assert.equal(rows[1]?.length, 10)
|
||||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '', '', 'registered', ''])
|
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
|
||||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', 'registered', ''])
|
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
|
||||||
|
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||||
@@ -35,8 +55,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
|||||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||||
})
|
})
|
||||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||||
assert.equal(rows[1]?.length, 8)
|
assert.equal(rows[1]?.length, 10)
|
||||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||||
@@ -49,5 +69,5 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
|||||||
athletes: [{ name: '王芳' }],
|
athletes: [{ name: '王芳' }],
|
||||||
memberNames: ['刘洋', '王芳'],
|
memberNames: ['刘洋', '王芳'],
|
||||||
})
|
})
|
||||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'scratched', '刘洋/王芳'])
|
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import type { ImportConflict } from '@/shared'
|
||||||
|
import { createApiClient } from '@/shared'
|
||||||
|
import {
|
||||||
|
REQUIRED_COLUMNS,
|
||||||
|
conflictDiffs,
|
||||||
|
decisionsPayload,
|
||||||
|
pruneDecisions,
|
||||||
|
recordDecisions,
|
||||||
|
summarizeDecisions,
|
||||||
|
toCsv,
|
||||||
|
type DecisionMap,
|
||||||
|
} from '../components/baseData/entrySelection'
|
||||||
|
import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||||
|
|
||||||
|
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
|
||||||
|
return {
|
||||||
|
row: 12,
|
||||||
|
athleteId: 42,
|
||||||
|
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: '1001' },
|
||||||
|
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||||
|
changedFields: ['teamCode'],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function csv(rows: string[][]): File {
|
||||||
|
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** event_code / category / team_code / athlete_name / id_card / gender / bib / seed / status / member_names */
|
||||||
|
function row(name: string, idCard: string, teamCode = 'F1', gender = ''): string[] {
|
||||||
|
return ['M100', '男子组', teamCode, name, idCard, gender, '', '', 'registered', '']
|
||||||
|
}
|
||||||
|
|
||||||
|
test('conflictDiffs 展开两侧字段并标出差异字段', () => {
|
||||||
|
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
|
||||||
|
assert.deepEqual(
|
||||||
|
diffs.map((diff) => diff.field),
|
||||||
|
['name', 'gender', 'teamCode', 'employeeNo'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
diffs.map((diff) => diff.label),
|
||||||
|
['姓名', '性别', '单位', '工号 / 学号'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
diffs.map((diff) => diff.changed),
|
||||||
|
[false, false, true, false],
|
||||||
|
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
|
||||||
|
)
|
||||||
|
assert.equal(diffs[3]?.incoming, '—', 'null 展示为占位符')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recordDecisions 只接受仍存在的证件号,且不改原 map', () => {
|
||||||
|
const conflicts = [conflict({ idCard: 'A' }), conflict({ idCard: 'B' })]
|
||||||
|
const current = recordDecisions({}, conflicts, ['A', '不存在的证件号'], 'keep-existing')
|
||||||
|
assert.deepEqual(current, { A: 'keep-existing' })
|
||||||
|
const next = recordDecisions(current, conflicts, ['B'], 'keep-new')
|
||||||
|
assert.deepEqual(next, { A: 'keep-existing', B: 'keep-new' })
|
||||||
|
assert.deepEqual(current, { A: 'keep-existing' }, '原 map 不应被修改')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收敛', () => {
|
||||||
|
const conflicts = [conflict({ idCard: 'B' }), conflict({ idCard: 'A' })]
|
||||||
|
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的证件号: 'keep-new' }
|
||||||
|
assert.deepEqual(pruneDecisions(decisions, conflicts), { A: 'keep-new', B: 'keep-existing' })
|
||||||
|
assert.deepEqual(
|
||||||
|
decisionsPayload(decisions, conflicts),
|
||||||
|
[
|
||||||
|
{ idCard: 'B', action: 'keep-existing' },
|
||||||
|
{ idCard: 'A', action: 'keep-new' },
|
||||||
|
],
|
||||||
|
'载荷顺序与冲突列表一致',
|
||||||
|
)
|
||||||
|
assert.deepEqual(summarizeDecisions(decisions, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
|
||||||
|
assert.deepEqual(summarizeDecisions({}, conflicts), { keepExisting: 0, keepNew: 0, undecided: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const before = mock.listAthletes().length
|
||||||
|
|
||||||
|
const created = await mock.importEntries(csv([row('新人甲', '110101199001010001', 'F1', '男')]))
|
||||||
|
assert.equal(created.created, 1)
|
||||||
|
assert.deepEqual(created.conflicts, [])
|
||||||
|
assert.equal(mock.listAthletes().length, before + 1)
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '新人甲')?.idCard, '110101199001010001')
|
||||||
|
|
||||||
|
// 种子数据里 F1 的「刘洋」证件号为空,应按姓名 + 单位回填而不是新建。
|
||||||
|
const backfilled = await mock.importEntries(csv([row('刘洋', '110101199001010002', 'F1')]))
|
||||||
|
assert.equal(backfilled.created, 1)
|
||||||
|
assert.equal(backfilled.conflicts.length, 0)
|
||||||
|
assert.equal(mock.listAthletes().length, before + 1, '回填不新增运动员')
|
||||||
|
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', async () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||||
|
assert.equal(missing.skipped, 1)
|
||||||
|
assert.match(missing.errors[0] ?? '', /必须填证件号/)
|
||||||
|
|
||||||
|
const duplicated = await mock.importEntries(
|
||||||
|
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
|
||||||
|
)
|
||||||
|
assert.equal(duplicated.created, 1, '第一行仍导入')
|
||||||
|
assert.equal(duplicated.skipped, 1)
|
||||||
|
assert.match(duplicated.errors[0] ?? '', /重复出现/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:同一证件号但单位不同记为冲突且不落库,决策后按所选一侧处理', async () => {
|
||||||
|
const idCard = '110101199001010004'
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||||
|
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||||
|
|
||||||
|
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||||
|
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
||||||
|
assert.equal(conflicted.conflicts.length, 1)
|
||||||
|
assert.deepEqual(conflicted.conflicts[0], {
|
||||||
|
row: 2,
|
||||||
|
idCard,
|
||||||
|
athleteId,
|
||||||
|
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: null },
|
||||||
|
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
|
||||||
|
changedFields: ['teamCode'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
|
||||||
|
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||||
|
assert.equal(again.conflicts.length, 1)
|
||||||
|
|
||||||
|
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||||
|
{ idCard, action: 'keep-existing' },
|
||||||
|
])
|
||||||
|
assert.equal(keptExisting.conflicts.length, 0)
|
||||||
|
assert.equal(keptExisting.created, 1)
|
||||||
|
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||||
|
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
|
||||||
|
|
||||||
|
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||||
|
{ idCard, action: 'keep-new' },
|
||||||
|
])
|
||||||
|
assert.equal(keptNew.conflicts.length, 0)
|
||||||
|
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||||
|
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
|
||||||
|
const captured: { url?: string; body?: FormData } = {}
|
||||||
|
const client = createApiClient({
|
||||||
|
baseUrl: '/api',
|
||||||
|
fetchImpl: async (input, init) => {
|
||||||
|
captured.url = String(input)
|
||||||
|
captured.body = init?.body as FormData
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: true, data: { created: 1, updated: 0, skipped: 0, errors: [], conflicts: [] } }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const file = new File(['a'], 'entries.csv', { type: 'text/csv' })
|
||||||
|
const decisions = [{ idCard: '110101199001010001', action: 'keep-new' as const }]
|
||||||
|
const result = await client.entries.resolveImport(file, decisions)
|
||||||
|
|
||||||
|
assert.equal(captured.url, '/api/entries/import/resolve')
|
||||||
|
assert.equal(captured.body?.get('decisions'), JSON.stringify(decisions))
|
||||||
|
assert.equal(captured.body?.get('file'), file)
|
||||||
|
assert.equal(result.created, 1)
|
||||||
|
assert.deepEqual(result.conflicts, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入:gender 只认男 / 女,其它值忽略', async () => {
|
||||||
|
const mock = new MockBaseData()
|
||||||
|
await mock.importEntries(csv([row('性别异常', '110101199001010005', 'F1', '未知')]))
|
||||||
|
const athlete = mock.listAthletes().find((item) => item.idCard === '110101199001010005')
|
||||||
|
assert.equal(athlete?.gender, null)
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
EventInput,
|
EventInput,
|
||||||
EventSettings,
|
EventSettings,
|
||||||
HealthInfo,
|
HealthInfo,
|
||||||
|
ImportConflictDecision,
|
||||||
Page,
|
Page,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
PointAdjustment,
|
PointAdjustment,
|
||||||
@@ -139,6 +140,8 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
|||||||
export interface EntriesApi {
|
export interface EntriesApi {
|
||||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||||
importFile(file: File): Promise<EntryImportResult>
|
importFile(file: File): Promise<EntryImportResult>
|
||||||
|
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
|
||||||
|
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResultsApi {
|
export interface ResultsApi {
|
||||||
@@ -381,6 +384,12 @@ export class ApiClient {
|
|||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
|
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
|
||||||
},
|
},
|
||||||
|
resolveImport: (file, decisions) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('decisions', JSON.stringify(decisions))
|
||||||
|
return http.request<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
this.results = createResultsApi(http)
|
this.results = createResultsApi(http)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface Athlete {
|
|||||||
gender: Gender | null
|
gender: Gender | null
|
||||||
teamId: number
|
teamId: number
|
||||||
employeeNo: string | null
|
employeeNo: string | null
|
||||||
|
/** 人员唯一标识(证件号);历史数据可能为空。 */
|
||||||
|
idCard: string | null
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +152,42 @@ export interface EntryWithResult extends Entry {
|
|||||||
result: Result | null
|
result: Result | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导入冲突中参与比对的人员字段。 */
|
||||||
|
export interface ImportConflictFields {
|
||||||
|
name: string
|
||||||
|
gender: Gender | null
|
||||||
|
teamCode: string
|
||||||
|
employeeNo: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportConflictField = keyof ImportConflictFields
|
||||||
|
|
||||||
|
/** 同一证件号在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
|
||||||
|
export interface ImportConflict {
|
||||||
|
/** 报名表原始行号(含表头,从 1 开始)。 */
|
||||||
|
row: number
|
||||||
|
idCard: string
|
||||||
|
athleteId: number
|
||||||
|
existing: ImportConflictFields
|
||||||
|
incoming: ImportConflictFields
|
||||||
|
changedFields: ImportConflictField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
|
||||||
|
export type ConflictAction = 'keep-existing' | 'keep-new'
|
||||||
|
|
||||||
|
export interface ImportConflictDecision {
|
||||||
|
idCard: string
|
||||||
|
action: ConflictAction
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntryImportResult {
|
export interface EntryImportResult {
|
||||||
created: number
|
created: number
|
||||||
updated: number
|
updated: number
|
||||||
skipped: number
|
skipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
|
||||||
|
conflicts: ImportConflict[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
|
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
|
||||||
|
|||||||
Reference in new issue
Block a user