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
@@ -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', '刘洋/王芳'])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user