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

@@ -1,23 +1,41 @@
/**
* 「从已有名单勾选报名」的纯函数部分。
* 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
*/
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
import type {
ConflictAction,
ImportConflict,
ImportConflictDecision,
ImportConflictField,
} from '@/shared'
/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */
export const REQUIRED_COLUMNS = [
'event_code',
'category',
'team_code',
'athlete_name',
'id_card',
'gender',
'bib',
'seed',
'status',
'member_names',
] 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 interface SelectionInput {
@@ -26,7 +44,8 @@ export interface SelectionInput {
teamCode: string
status?: string
mode: SelectionMode
athletes: { name: string }[]
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
athletes: { name: string; idCard?: string | null }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
memberNames?: string[]
}
@@ -42,12 +61,23 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
const rows: string[][] = [[...REQUIRED_COLUMNS]]
if (input.mode === 'individual') {
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
}
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
}
@@ -60,3 +90,91 @@ export function toCsv(rows: string[][]): string {
function escapeField(field: string): string {
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
}