feat: 人员唯一标识改为证件号 + 导入冲突逐条决策

需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据,
先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。

服务端:
- 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存)
- athleteDTO/CRUD 读写 idCard,重复返回 409
- 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号
- 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员
- 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建)
- 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new
- 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引

前端:
- 运动员表单与列表新增「证件号」,搜索支持证件号
- 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮
  + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见
- 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示
- 新增 import-conflicts.test.ts 等,前端测试 39/39

文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。

注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
This commit is contained in:
AskaEth committed 2026-09-10 02:09:15 +08:00
1 parent c9d5b8ea89
commit 24fff8dd08
21 files changed
+1590 -67

No files matched your search

+29 -9
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test'
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 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a', 'b']]), 'a,b')
@@ -11,19 +17,33 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
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({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '张伟' }, { name: '李强' }],
athletes: [
{ name: '张伟', idCard: '110101199001011234' },
{ name: '李强', idCard: null },
],
})
assert.equal(rows.length, 3, '表头 + 2 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '', '', 'registered', ''])
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', 'registered', ''])
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
@@ -35,8 +55,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'registered', '王芳/刘洋/陈晨'])
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
@@ -49,5 +69,5 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'scratched', '刘洋/王芳'])
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
})