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
@@ -19,6 +19,7 @@ import type {
|
||||
EventInput,
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
ImportConflictDecision,
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
@@ -139,6 +140,8 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
||||
export interface EntriesApi {
|
||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
|
||||
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
|
||||
}
|
||||
|
||||
export interface ResultsApi {
|
||||
@@ -381,6 +384,12 @@ export class ApiClient {
|
||||
form.append('file', file)
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface Athlete {
|
||||
gender: Gender | null
|
||||
teamId: number
|
||||
employeeNo: string | null
|
||||
/** 人员唯一标识(证件号);历史数据可能为空。 */
|
||||
idCard: string | null
|
||||
active: boolean
|
||||
}
|
||||
|
||||
@@ -150,11 +152,42 @@ export interface EntryWithResult extends Entry {
|
||||
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 {
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
|
||||
conflicts: ImportConflict[]
|
||||
}
|
||||
|
||||
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
|
||||
|
||||
Reference in new issue
Block a user