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
@@ -83,6 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
|
||||
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
|
||||
}
|
||||
|
||||
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
|
||||
function missingIdCard(athlete: Athlete): boolean {
|
||||
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
|
||||
}
|
||||
|
||||
async function loadEntries(id: number): Promise<void> {
|
||||
entriesLoading.value = true
|
||||
try {
|
||||
@@ -133,7 +138,7 @@ async function submit(): Promise<void> {
|
||||
categoryName: category.name,
|
||||
teamCode: team.code,
|
||||
mode: mode.value,
|
||||
athletes: chosen.map((athlete) => ({ name: athlete.name })),
|
||||
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
|
||||
})
|
||||
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
||||
const result = await data.importEntries(file)
|
||||
@@ -222,11 +227,12 @@ async function submit(): Promise<void> {
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="athlete.id"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete)"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
|
||||
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
|
||||
</label>
|
||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
@@ -238,6 +244,7 @@ async function submit(): Promise<void> {
|
||||
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
||||
</p>
|
||||
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
|
||||
|
||||
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user