feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。
## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。
## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
`entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。
## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。
## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。
## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。
## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。
## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。
## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
7a3e4ad365
commit
99c91f99f7
57 files changed
+4903
-94
No files matched your search
@@ -9,11 +9,13 @@
|
||||
|
||||
import type {
|
||||
ConflictAction,
|
||||
EntryAthlete,
|
||||
EntryAthleteInput,
|
||||
ImportConflict,
|
||||
ImportConflictDecision,
|
||||
ImportConflictField,
|
||||
} from '@/shared'
|
||||
import { teamSquadLabel } from '@/shared'
|
||||
import { entryRosterHead, teamSquadLabel } from '@/shared'
|
||||
|
||||
/**
|
||||
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
|
||||
@@ -220,17 +222,21 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
|
||||
*
|
||||
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
|
||||
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
|
||||
*
|
||||
* 「首人」的口径没变(一行只念一个人):有队员关联时它是首名队员 `张伟(A001)`——
|
||||
* 号码布让人对得上是谁(重名时唯一的线索);没有关联时就是文字名单的第一个人,与升级前逐字一样。
|
||||
*/
|
||||
export function entryRowLabel(row: {
|
||||
athleteName?: string | null
|
||||
memberNames?: string[] | null
|
||||
athletes?: readonly EntryAthlete[] | null
|
||||
teamShort?: string | null
|
||||
teamName?: string | null
|
||||
squad?: string | null
|
||||
}): string {
|
||||
return (
|
||||
row.athleteName ||
|
||||
row.memberNames?.[0] ||
|
||||
entryRosterHead(row) ||
|
||||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
|
||||
)
|
||||
}
|
||||
@@ -241,9 +247,16 @@ export interface SelectionInput {
|
||||
teamCode: string
|
||||
status?: string
|
||||
mode: SelectionMode
|
||||
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。 */
|
||||
athletes: { name: string; idCard?: string | null; bib?: string | null }[]
|
||||
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
|
||||
/**
|
||||
* 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。
|
||||
* `id` 是人员档案的 id:团体项目靠它把勾中的人提交成**队员关联**(写侧只认 athleteId)。
|
||||
*/
|
||||
athletes: { id?: number; name: string; idCard?: string | null; bib?: string | null }[]
|
||||
/**
|
||||
* 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。
|
||||
*
|
||||
* 这是**文字名单**(导入表的 member_names 列),与队员关联并存:没录档案的人只能留文字。
|
||||
*/
|
||||
memberNames?: string[]
|
||||
}
|
||||
|
||||
@@ -284,15 +297,56 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
eventCode: input.eventCode,
|
||||
categoryName: input.categoryName,
|
||||
status,
|
||||
teams: [{ teamCode: input.teamCode, memberNames: names }],
|
||||
teams: [
|
||||
{
|
||||
teamCode: input.teamCode,
|
||||
memberNames: names,
|
||||
// 文字名单可以覆盖(人手写的名单),队员关联只认勾中的、带档案 id 的那几个人。
|
||||
athletes: teamAthletesOf(input.athletes),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** 一个单位的团体报名:只带单位编码与可选队员名单。 */
|
||||
/**
|
||||
* 勾中的人 → 队员关联(只有带 id 的才算:没有 id 的人只在文字名单里,见 devdocs/20 §五)。
|
||||
*
|
||||
* 顺序就是传入顺序(界面上的列表顺序),写进去的 sort_order 跟着它,队里的排列才稳定。
|
||||
*/
|
||||
function teamAthletesOf(
|
||||
athletes: readonly { id?: number; name: string; bib?: string | null }[],
|
||||
): EntryAthlete[] {
|
||||
const out: EntryAthlete[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const athlete of athletes) {
|
||||
const athleteId = athlete.id
|
||||
if (athleteId === undefined || !Number.isInteger(athleteId) || athleteId <= 0) continue
|
||||
if (seen.has(athleteId)) continue
|
||||
seen.add(athleteId)
|
||||
out.push({ athleteId, name: athlete.name, bib: athlete.bib ?? null })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 一个单位的团体报名:单位编码 + 可选队员名单 + 可选的队员关联。 */
|
||||
export interface TeamSelection {
|
||||
/**
|
||||
* 单位 id:提交后要靠它(连同组别与队名)找回刚写进去的那条报名,
|
||||
* 才谈得上把队员关联挂上去。缺省 = 不打算同步队员(例如只想拿这份选举行生成 CSV)。
|
||||
*/
|
||||
teamId?: number
|
||||
teamCode: string
|
||||
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
|
||||
/**
|
||||
* 队员姓名(导入表的 member_names 列):空数组表示只报单位,名单之后可以再补。
|
||||
*
|
||||
* 与 `athletes` **并存**:这是给没录档案的人留的文字名单,也是老数据唯一有的东西。
|
||||
*/
|
||||
memberNames: string[]
|
||||
/**
|
||||
* 队员关联(运动员档案里的人):勾中的人进这里,提交后走 PUT /api/entries/{id}/athletes。
|
||||
* 缺省 / 空数组 = 这条报名不挂任何队员(只报单位)。
|
||||
*/
|
||||
athletes?: EntryAthlete[]
|
||||
/**
|
||||
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
|
||||
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
|
||||
@@ -420,26 +474,85 @@ export function selectableTeamIds(
|
||||
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
|
||||
* 保证同一份勾选每次生成的 member_names 完全一致。
|
||||
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
|
||||
*
|
||||
* 勾中的人同时产出**队员关联**(athleteId,写侧只认它)——姓名文字继续进 member_names
|
||||
* (导出、大屏、录入页都读它),身份进 athletes:个人分与个人奖牌挂的就是后者。
|
||||
*/
|
||||
export function buildTeamSelections(
|
||||
state: TeamPickState,
|
||||
teams: readonly { id: number; code: string }[],
|
||||
athletes: readonly { id: number; teamId: number; name: string; active: boolean }[],
|
||||
athletes: readonly { id: number; teamId: number; name: string; active: boolean; bib?: string | null }[],
|
||||
): TeamSelection[] {
|
||||
const picked = new Set(state.teamIds)
|
||||
const selections: TeamSelection[] = []
|
||||
for (const team of teams) {
|
||||
if (!picked.has(team.id)) continue
|
||||
const memberIds = new Set(state.memberIds[team.id] ?? [])
|
||||
const memberNames = athletes
|
||||
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
|
||||
.map((athlete) => athlete.name)
|
||||
const members = athletes.filter(
|
||||
(athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id),
|
||||
)
|
||||
const memberNames = members.map((athlete) => athlete.name)
|
||||
const squad = (state.squads?.[team.id] ?? '').trim()
|
||||
selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad })
|
||||
const selection: TeamSelection = {
|
||||
teamId: team.id,
|
||||
teamCode: team.code,
|
||||
memberNames,
|
||||
athletes: teamAthletesOf(members),
|
||||
}
|
||||
selections.push(squad === '' ? selection : { ...selection, squad })
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
/** 一条要写回去的队员关联:先按 组别 + 单位 + 队伍 找到那条报名,再整组替换它的队员。 */
|
||||
export interface TeamAthleteSync {
|
||||
entryId: number
|
||||
athletes: EntryAthleteInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 补报名提交后要发的队员写请求:勾了人的单位、以及**原来挂着队员而这次一个都没勾**的单位。
|
||||
*
|
||||
* 为什么不是「所有勾选的单位都发一次」:队员是整组替换,发空数组 = 清空。一个单位都没勾队员的
|
||||
* 单位本来就没关联,也发一次只是白发一趟请求(「全选」几十个单位就是几十次写请求,会撞写限流)。
|
||||
* 反过来,原来有队员、这次一个都没勾的必须先写空数组:那条报名在界面上会显示成清空了名单,
|
||||
* 不写就要留着一个谁都没在里面的队员关联,个人榜继续给早就换掉的人发分。
|
||||
*
|
||||
* 队名要一模一样才算同一条报名(同一个单位的一队与二队是两条报名,见迁移 0025),
|
||||
* 所以这里按 组别 + 单位 + 归一后的队名 找;组别对不上、找不到(导入那一步没写进去)的一律跳过,
|
||||
* 由导入那边的行级报错去说,不在这里猜。
|
||||
*/
|
||||
export function planTeamAthleteSync(
|
||||
selections: readonly TeamSelection[],
|
||||
entries: readonly {
|
||||
id: number
|
||||
categoryId: number
|
||||
teamId: number
|
||||
squad?: string | null
|
||||
athletes?: readonly EntryAthlete[] | null
|
||||
}[],
|
||||
categoryId: number | undefined,
|
||||
): TeamAthleteSync[] {
|
||||
const plan: TeamAthleteSync[] = []
|
||||
if (categoryId === undefined) return plan
|
||||
for (const selection of selections) {
|
||||
const teamId = selection.teamId
|
||||
if (teamId === undefined) continue
|
||||
const squad = (selection.squad ?? '').trim()
|
||||
const entry = entries.find(
|
||||
(item) =>
|
||||
item.categoryId === categoryId &&
|
||||
item.teamId === teamId &&
|
||||
(item.squad ?? '').trim() === squad,
|
||||
)
|
||||
if (entry === undefined) continue
|
||||
const athletes = selection.athletes ?? []
|
||||
if (athletes.length === 0 && (entry.athletes ?? []).length === 0) continue
|
||||
plan.push({ entryId: entry.id, athletes: athletes.map((athlete) => ({ athleteId: athlete.athleteId })) })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
|
||||
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
|
||||
return pickedTeamCount > 0
|
||||
|
||||
Reference in new issue
Block a user