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,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
|
||||
}
|
||||
Reference in new issue
Block a user