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:
AskaEth committed 2026-09-14 14:05:48 +08:00
1 parent 7a3e4ad365
commit 99c91f99f7
57 files changed
+4903 -94

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.5 dev_0.5.6
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.5 0.5.6
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scoreboard-console", "name": "scoreboard-console",
"productName": "Scoreboard Console", "productName": "Scoreboard Console",
"version": "0.5.5", "version": "0.5.6",
"private": true, "private": true,
"author": "AskaEth", "author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+42 -2
View File
@@ -100,7 +100,7 @@
### 4.2 录入成绩 ### 4.2 录入成绩
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。
**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。 **「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。
@@ -342,6 +342,45 @@
- **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变); - **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变);
- 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。 - 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。
### 4.13 团体报名的队员(谁在这支队里)
**团体/对抗项目(双打、接力、拔河、篮球…)的报名上可以挂「队员」**——挂上之后,系统知道**这支队伍里坐着谁**。这条报名在配了「个人通道」的项目里就会**给每个队员各发一份完整的个人分、各一枚个人奖牌**,他们也因此能上**个人积分榜**(过去只存一串姓名文字,谁都上不了个人榜)。
**「每人各一份」不是「几个人分同一份」**:一对双打赢了拿 10 分,是**两个人各 10 分**(不是各 5 分)——这与「联队把分拆给几个单位」正相反。**单位总分与单位奖牌不受影响**:一个单位拿到多少分、多少牌,由「计分通道」里的 `计入单位总分` / 个人奖牌是否并入单位榜两个开关决定;**双打现场通常都设成不计入**,也就是个人分给到人、单位那本账不变。
**怎么挂队员(三条路)**
| 方式 | 怎么做 |
| --- | --- |
| **补报名时勾人** | 「成绩录入」页工具栏的 **「补报名」**(或「基础数据 → 报名导入」的**团体报名**页签):勾上单位后可以**登记队员**(逐个勾选,可按姓名 / 工号搜索)。**勾上的人会真正提交上去**,写进那一条团体报名 |
| **录入页改队员** | 「成绩录入」页表格里**「运动员 / 队」那一格旁边的「队员」小按钮**(**只有纯团体行才有**)→ 打开勾选式名单(带搜索)→ 勾完保存。这是**把已经报过的报名补挂 / 改挂队员**的地方 |
| **导入报名表** | 把「队员名单」那一格写成**号码布**(例如 `101/102`),导入时会自动关联到人;**写姓名只当文字**,详见[报名名单导入规范 · 第三节「队员名单」](./报名名单导入规范.md) |
**号码布是唯一标识**
- 系统**只按号码布认人**(`101` → 号码布是 101 的那个人)。**姓名一律不参与匹配**——同名会认错人,而认错的分已经上了榜;
- 所以**「队员名单」里只写姓名是拿不到个人分的**(名单照常显示,也**不报错**);界面上手动勾人不受这条限制(勾的是人);
- 号码布认到的是**别的单位**的人时**照常关联**,并给一条提示让你核对号码布是不是抄错了(号码布是唯一标识,系统不看单位)。
**显示成什么样**
- **导出件**:一个队员写 **`姓名(号码布)`**(例如 `张三(101)`),多个用 `、` 连——报名名单的「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都这样;**个人得分明细里每个队员各占一行**(双打就是两行,各一整份分);
- **大屏**:分项成绩 / 实时赛况的运动员列、底栏「最新完成项目」的名字格都会带上队员名单(**只写姓名**,号码布太长会把整屏字号压小);
- ⚠️ **一条报名仍然只占一行**,不会因为挂了两个队员就变成两行(队里的两个人是**一条报名**里的两个身份);
- ⚠️ **队名(4.12)与队员是两件独立的事**:单位列照旧显示「单位名 + 队名」,队员只出现在运动员列 / 底栏。
**老数据怎么办**
- 升级时会**尽力回填**一次:拿原来那串姓名文字去对——**先按号码布认**,再按「姓名 + 单位」(**同一个单位同名的人只有一个**时才认);**认不上的就留空**(不硬猜,避免把分记到别人头上);
- 所以**升级后请抽查几个团体报名**:只写姓名(或没写队员)的老报名,很可能**还是没有队员身份**——需要的话,到录入页点「队员」按钮手动挂上,或重新导入一份**写着号码布**的报名表;
- ⚠️ **改完队员之后**:这条报名的个人分、个人奖牌会**当场重算并推榜**;但**名单文字(`member_names`)与队员关联是并存的**——名字文字里没被认成人的那些名字**永远上不了个人榜**,要上榜就得挂成队员。
**要注意的**
- **权限**:读得到成绩的人都能看见;**能改的是录入员及以上**(与录成绩同档)。访客只读时按钮置灰;示例数据模式下不能保存;
- **一条报名最多 30 个队员**(超过会被拦下,多半是号码布写错了位置);**个人报名(按人报的那些)没有队员**(它自己就是那个人的身份);
- **改队员不会重算整张榜**(与改队名一样只重算这条报名所在的组别),**也不会给别处的大屏推刷新**——别的窗口要等下一次刷新才看到新名单。
--- ---
## 5. 成绩审核 ## 5. 成绩审核
@@ -756,7 +795,8 @@
1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员; 1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员;
2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12; 2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12;
3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补; 3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补。
- **这里勾上的队员会真正提交到报名上**(不是只存一串姓名):配了「个人通道」的项目里,他们会各拿一份个人分、各一枚个人奖牌,也上得了个人积分榜——详见 **4.13**;
4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单; 4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单;
5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。 5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。
- 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。 - 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。
+54 -1
View File
@@ -6,7 +6,60 @@
--- ---
## dev_0.5.5(当前版本) ## dev_0.5.6(当前版本)
### 一句话
**有一条数据迁移(`0026`),老数据一个字节都不变**:团体 / 对抗项目的报名现在能**挂具体的队员了**——界面本来就能一次勾多个运动员,但以前提交时只把姓名拼成一串文字,**身份丢了**,所以配了「个人通道」的团体项目**给不出个人分、个人奖牌,队员也上不了个人积分榜**。升级后:队员**每人各拿一份完整的个人分、各一枚个人奖牌**(是「每人各一份」,不是几个人分同一份);**单位总分与单位奖牌不受影响**(由计分通道的开关决定,双打现场通常都设成不计入)。没有挂队员的老报名、以及只配了团体通道的项目(现场绝大多数球类项目),**行为与升级前逐字一样**。
### ① 数据迁移:一条(`0026`)
- **它做什么**:新建一张 `entry_athletes` 表,记「这条报名里有哪几个运动员」;**报名表本身一个字节没动**(原来那串姓名文字 `member_names` 保留为**显示用名单**,报名的唯一键、队名、名单导出都不受影响)。
- **老数据回填(尽力而为,匹配不上就留空)**:拿原来那串姓名文字去对运动员档案——**先按号码布认**;号码布对不上再按「**姓名 + 单位**」,且**同一个单位里同名的人只有一个**时才认(重名留给人工确认);**认不上的不猜,留空**(维持升级前的行为:这条报名只有单位、没有队员身份)。
- **回填之后会发生什么**:配了**个人通道**的老团体项目会**开始给这些队员发个人分与个人奖牌**(这正是这一版的目的);**只配了团体通道的项目一个字节都不变**(单位总分、单位奖牌、榜单都不动)。⚠️ 所以升级后请**抽查几个团体项目**:原来只写姓名的报名可能仍然没有队员(那就手动挂上,见下)。
- **怎么看它跑过**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到 `已应用迁移 … name=0026_entry_athletes.sql`。
### ② 新能力:团体报名的队员(谁在这支队里)
| 场景 | 怎么做 |
| --- | --- |
| **导入报名表** | 「队员名单」那一格写**号码布**(例如 `101/102`)→ 自动关联到人;**写姓名只当文字**(名单照常显示,但拿不到个人分),也**不报错**。⚠️ 号码布要**单独成段**:`张三(101)` 这种混写认不出人;一条报名最多 30 人 |
| **补报名时勾人** | 「成绩录入」页的「补报名」/「基础数据 → 报名导入」的团体报名页签:勾上人**会真正提交**到报名上 |
| **录入页改队员** | 「成绩录入」页表格里**「运动员 / 队」那一格旁边的「队员」小按钮**(只有纯团体行才有)→ 勾选式名单(带搜索)→ 保存。改完**当场重算该组别并推榜** |
**口径**:
- **号码布是运动员的唯一标识**(不是证件号、也不是姓名):系统**只按号码布认人**;号码布认到的是**别的单位**的人时**照样关联**,只给一条提示让你核对;
- **每人各拿一份完整的分**(一对双打赢了拿 10 分 → 两个人各 10 分),**各一枚个人奖牌**;
- **单位那本账不变**:单位总分与单位奖牌由「计分通道」的 `计入单位总分` / 个人奖牌是否并入单位榜两个开关决定;**双打现场通常都是不计入**;
- **个人报名(按人报名的那些)没有队员**——它自己就是那个人的身份;**一条报名仍然只占一行**(挂了两个队员也不会变成两行)。
### ③ 显示:一个队员写成 `姓名(号码布)`
- **导出件**:报名名单「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都写成 `张三(101)`,多个用 `、` 连;**个人得分明细里每人各占一行**(双打两行,各一整份分);
- **大屏**:分项成绩 / 实时赛况的运动员列、底栏「最新完成项目」都带上队员名单(**只写姓名**,号码布太长会把整屏字号压小);
- ⚠️ **没有队员关联的老数据一个字不变**:老报名的导出件与大屏画面与升级前逐字 / 逐像素一致(姓名文字照旧、行为照旧)。
### ④ 升级后 5 分钟核对
1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0026` 这条迁移记录,没有别的报错;
2. 刷新管理端「积分榜」与大屏:**单位总分榜、单位奖牌榜的数字与升级前一致**(本版不动单位那本账);
3. 打开「成绩录入」页:挑一条**团体报名**看「运动员 / 队」那一格——纯团体行旁边应有一个**「队员」小按钮**(个人行没有,这是对的);点开看看有没有队员(老报名可能一个都没有,那是回填没认上,属正常);
4. 挑一个**原来只写了姓名**的团体报名(羽毛球双打那种),点「队员」按钮**把队员勾上**保存;再到「积分榜 → 个人积分榜」确认**这些队员出现了、各有一条分**;
5. 拿一份**写着号码布**的报名表(「队员名单」列写成 `101/102`)试导一次,导入结果里应能看到一条关于队员的汇总提示;
6. 用《上线检查清单》过一遍。
### ⑤ 详细说明在哪
| 想了解 | 看 |
| --- | --- |
| 队员怎么挂、改在哪、显示成什么样 | 《使用手册》4.13(以及 4.2、10.7 的衔接说明) |
| 导入表里「队员名单」怎么写(号码布 / 姓名各是什么效果) | 《报名名单导入规范》第三节「`member_names` 队员名单」、第四节的例 6、第六节 |
| 迁移与两处判定 | `server/migrations/event/0026_entry_athletes.sql` |
---
## dev_0.5.5
### 一句话 ### 一句话
+54 -4
View File
@@ -45,7 +45,7 @@
| `seed` | 种子(也认「道次」) | 道次 / 种子 | | `seed` | 种子(也认「道次」) | 道次 / 种子 |
| `heat` | 批次(也认「批次号」) | 批次,可缺省 | | `heat` | 批次(也认「批次号」) | 批次,可缺省 |
| `status` | 状态(也认「报名状态」) | 报名状态 | | `status` | 状态(也认「报名状态」) | 报名状态 |
| `member_names` | 队员名单 | 团体项目的队员名单 | | `member_names` | 队员名单 | 团体项目的队员名单;**写号码布才会关联到人**(写姓名只当文字) |
| `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) | | `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) |
> **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。 > **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。
@@ -69,7 +69,7 @@
| `seed`(种子) | 可选 | 道次 / 种子,随便写或留空 | `种子1` | | `seed`(种子) | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
| `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` | | `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
| `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` | | `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` | | `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开。**写号码布 = 关联到那个人(能拿个人分)**;只写姓名 = 只当文字 | `101/102`(号码布);`张三/李四`(仅文字) |
| `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` | | `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` |
> 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。 > 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。
@@ -180,6 +180,7 @@ F1+F2
> 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空) > 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)
### `member_names` 队员名单 ### `member_names` 队员名单
团体项目用这一列写队员,多人之间用 `/`、`,` 或 `,` 分开: 团体项目用这一列写队员,多人之间用 `/`、`,` 或 `,` 分开:
``` ```
@@ -190,6 +191,31 @@ F1+F2
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
- 系统界面里会把这条名单显示成「张三、李四、王五」。 - 系统界面里会把这条名单显示成「张三、李四、王五」。
**想让队员真正「上场」——这一列要写号码布**
这一列有**两种内容**,系统按段(用 `/`、`,`、`,` 分开的每一段)分别处理:
| 这一段写的是 | 系统怎么处理 |
| --- | --- |
| **某个运动员的号码布**(例如 `101`、`A001`) | **认成那个人**:把这条团体报名与他**关联**起来(这一格也原样保留成文字)。**关联上的队员才能拿个人分、上个人积分榜、拿个人奖牌**(该项目配了「个人通道」时) |
| **只有姓名**(例如 `张三`) | **只当文字**:名单照常显示,**不会关联到人**(姓名可能重名,认错的分已经上了榜——所以系统**故意不按姓名认人**),也**不报错** |
```
101/102 ← 推荐写法:号码布,两个队员都能拿到个人分
张三/李四 ← 只当文字:名单会显示,但这两个人没有身份(拿不到个人分)
101/李四 ← 混着写也行:101 关联到人,李四只当文字
```
几条要记住的:
- ⚠️ **号码布要单独成一段**:`张三(101)` 这种「姓名和号码布写在同一段」的写法**认不出人**(系统只拿整段去比对号码布),要写成 `张三/101`,或者干脆只写 `101`。**推荐只写号码布**;
- **号码布是运动员的唯一标识,不看单位**:号码布认到的是**别的单位**的人时,**照常关联**,并在导入结果里给一条提示让你核对号码布是不是写错了(不是冲突、不拦截);
- **认不出人的段不报错**:没录档案的临时队员、写错的名字,都只当文字留在名单里(这条报名仍然只有单位、没有队员身份);
- **同一格里同一个号码布写两次**:只关联一次,并给一条提示(同一条报名里一个人只占一个位置);
- **个人行(填了姓名的那几行)这一列只当文字**:个人报名自己就是那个人的身份,不存在「队员」。这一格里写了号码布也不关联,只会给一条提示;
- **一条报名最多关联 30 个人**:超过的那一行会被**整行拦下**(多半是把号码布写错了位置);
- **老数据的人要补挂**:升级前已经报好的团体报名,如果当时只写了姓名(或连姓名都没写),**升级时会尽力按「号码布 → 姓名 + 单位」回填一次**;**回填不上的,要重新导入一份写着号码布的名单,或到录入页手动挂人**(见[使用手册 · 4.13](./使用手册.md))。
### `squad` 队伍 —— 可选(表的最后一列) ### `squad` 队伍 —— 可选(表的最后一列)
**这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。 **这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。
@@ -220,7 +246,7 @@ F1+F2
--- ---
## 四、五个完整例子 ## 四、六个完整例子
### 例 1:个人项目(七单位 × 若干人) ### 例 1:个人项目(七单位 × 若干人)
@@ -289,6 +315,24 @@ F1+F2
- 显示上写成 **`第一分公司一队` / `第一分公司二队`**(单位名后面直接跟队名,不加括号不加分隔符);**单位总分榜、单位奖牌榜、个人积分榜上只写 `第一分公司`**(两队的分数都已经算进这个单位的总分); - 显示上写成 **`第一分公司一队` / `第一分公司二队`**(单位名后面直接跟队名,不加括号不加分隔符);**单位总分榜、单位奖牌榜、个人积分榜上只写 `第一分公司`**(两队的分数都已经算进这个单位的总分);
- 报了之后想改队名**不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ **改完记得把这张表里的队名也改成一样**——以后再导一份还写着旧队名的表,会**新增出旧队名那一条报名**(导入是按「小项 + 组别 + 单位 + 队名」认人的)。 - 报了之后想改队名**不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ **改完记得把这张表里的队名也改成一样**——以后再导一份还写着旧队名的表,会**新增出旧队名那一条报名**(导入是按「小项 + 组别 + 单位 + 队名」认人的)。
### 例 6:同一支队里的两个队员(队员名单写号码布)
`BD` 羽毛球双打、`男子组`,F1 的「一队」是张三(号码布 `101`)与李四(号码布 `102`);F2 那支队只报了单位、没写队员:
| event_code | category | team_code | athlete_name | id_card | gender | age | bib | seed | heat | status | member_names | squad |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| BD | 男子组 | F1 | | | | | | | | registered | 101/102 | 一队 |
| BD | 男子组 | F2 | | | | | | | | registered | | |
要点:
- **队员名单里写号码布,系统才认得出队里坐着谁**:`101/102` → 这条报名关联到张三、李四两个人。**该项目配了「个人通道」时,两个人各拿一份完整的个人分、各一枚个人奖牌,都上个人积分榜**(是「每人各一份」,不是两个人分同一份);
- **单位总分与单位奖牌不受影响**:上不上的开关在「计分通道」里(`计入单位总分` / 个人奖牌是否并入单位榜)。**双打现场通常都设成「不计入」**——个人分给到人,单位那本账不变;
- **只写姓名(`张三/李四`)也合法**:名单照常显示,但**这两个人没有身份**——拿不到个人分、上不了个人榜,也**不报错**。要让他们上榜,把这一格改成他们的**号码布**(`101/102`),或到录入页手动挂人(见[使用手册 · 4.13](./使用手册.md));
- ⚠️ **号码布要单独成段**:写成 `张三(101)/李四(102)` **认不出人**(系统拿整段去比号码布)。要「名字 + 号码布」都看得见,就先写号码布(`101/102`)——导出件与录入页会自己显示成 `张三(101)、李四(102)`;
- **号码布写错成别的单位的人**:照号码布关联,并给一条提示让你核对(号码布是唯一标识,系统不按单位猜);
- **F2 那一行留空**:只报单位、没有队员身份,这是合法的,也是升级前所有团体报名的样子。
--- ---
## 五、导入前请先检查(自检清单) ## 五、导入前请先检查(自检清单)
@@ -306,6 +350,7 @@ F1+F2
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组) - [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
- [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节) - [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节)
- [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示) - [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示)
- [ ] **要让队员拿到个人分**:`member_names`(队员名单)里写的是**单独的号码布**(`101/102`),不是姓名、也不是 `张三(101)` 这种混写;号码布抄对(写错会关联到别人,系统会提示但不会拦)
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
- [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节) - [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节)
@@ -331,6 +376,11 @@ F1+F2
| 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) | | 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) |
| 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 | | 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 |
| 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 | | 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 |
| 第 N 行:队员名单里认出了 31 名运动员,超过一条报名最多 30 人:请检查这一格是不是把号码布写错了位置 | 这一格里能对上号码布的段**超过 30 个**(多半是把别的内容写进去了) | 核对这一格:一条报名最多 30 个队员;只有一支队时这一格留空即可。**这一行整行未导入** |
| 第 N 行:队员名单里的号码布 101 写了两次,只关联一个人(同一条报名里一个人只占一个位置) | 同一格里同一个号码布写了两次 | 不算错误(照常导入、只关联一次);把这一格里重复的那个号码布删掉即可 |
| 第 N 行的队员名单里有 2 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人 | **个人行**(有姓名的行)里写了号码布。个人报名自己就是那个人的身份,不存在「队员」 | 不算错误(这一行照常导入,只是**没有**建立队员关联);团体报名的那一行才需要写号码布 |
| 第 N 行:队员名单里的号码布 205 是单位 F3 的 李四,本行报名挂在单位 F1 名下 —— 号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错 | 号码布认到的是**别的单位**的人。号码布是唯一标识,系统**照它认、不看单位** | 先核对号码布有没有抄错:抄错了改成正确的号码布;**确实就是借调/跨单位的人**,这条提示可以忽略(关联已经生效) |
| **(没有报错,但名单里的人上不了个人榜)** | 队员名单里写的是**姓名**(例如 `张三/李四`),或号码布写成了 `张三(101)` 这种「姓名 + 号码布写在同一段」的形式 —— 系统拿**整段**去比号码布,认不出人 | 这是**正常行为**(不报错):把这一段改成**单独的号码布**(`101/102`),队员才会被关联、才会拿到个人分。要「姓名 + 号码布」都显示,直接写号码布即可(导出件与录入页会显示成 `张三(101)`) |
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
@@ -436,7 +486,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿
> 2. **一个人一行**;一个人报几项就写几行; > 2. **一个人一行**;一个人报几项就写几行;
> 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编; > 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编;
> 4. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**; > 4. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**;
> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开; > 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开——**写号码布(`101/102`)才会关联到人、才拿得到个人分**,只写姓名就只是文字名单;
> 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号; > 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号;
> 7. 表里**不要合并单元格**、不要加标题行、不要插图; > 7. 表里**不要合并单元格**、不要加标题行、不要插图;
> 8. 填完先对着第五节的清单自查一遍,再发给收表人; > 8. 填完先对着第五节的清单自查一遍,再发给收表人;
@@ -0,0 +1,267 @@
package domain
import (
"fmt"
"strings"
"testing"
)
// 本文件钉住「团体报名的队员身份」在引擎里的口径(devdocs/20):
//
// - 一条团体报名(athlete_id 为空)可以挂多个队员;个人通道给**每个队员各一份完整的**分
// (不是按份额拆,与联队 entry_team_shares 的「拆」相反),个人奖牌也是每人各一枚;
// - **单位维度一份都不多**:单位总分与单位奖牌仍然只算这条报名一次,由通道开关
// count_to_team / count_to_team_medals 控制;
// - 没有队员关联的老数据(Entry.Athletes 为空)逐字节不变。
// memberEntry 造一条带队员的团体报名。
func memberEntry(entryID, teamID int64, value float64, members ...EntryAthlete) participant {
return participant{entryID: entryID, teamID: teamID, value: value, members: members}
}
// twoDoubles 是「一个单位派两个人打双打 + 另一个单位一个人」的最小现场形态:
// 成绩越小越好(compare=asc,与 100 米那种打法一致)。
func twoDoubles() ([]Entry, []Result) {
return fixture([]participant{
memberEntry(1, 1001, 11.2,
EntryAthlete{AthleteID: 201, Name: "队员一"},
EntryAthlete{AthleteID: 202, Name: "队员二"}),
{entryID: 2, teamID: 1002, value: 11.5},
})
}
// twoDoublesLegacy 是升级前的老数据形态:同样两条报名、同样的成绩,但队员只有姓名文字
// (认不出是哪个人),也就是回填不出关联或干脆没回填的那些报名。
func twoDoublesLegacy() ([]Entry, []Result) {
entries, results := twoDoubles()
entries[0].Athletes = nil
return entries, results
}
// doublesEventInput 是双打的计分输入:团体项目 + 一个按成绩排名的个人通道。
// teamFlagsOff 打开通道的两个单位开关「都不计入」(现场的双打口径)。
func doublesEventInput(entries []Entry, results []Result, teamFlagsOff bool) EventScoreInput {
ch := athleteResultChannel(9)
if teamFlagsOff {
ch.CountToTeam = boolPtr(false)
ch.CountToTeamMedals = boolPtr(false)
}
return EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch},
Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
}
}
// 双打:两个队员各拿一份完整的个人分(第 1 名 10 分 → 两人各 10 分,不是各 5 分),
// 单位总分仍然只有一份 10 分;个人奖牌每人各一枚,单位奖牌只有一枚。
func TestTeamEntryMembersScoreIndividually(t *testing.T) {
entries, results := twoDoubles()
es := mustScore(t, doublesEventInput(entries, results, false))
// 名次明细:报名项自己那一份(单位维度认它)+ 每个队员一份个人副本。
wantRanked := []string{
"key=e:1 scope=athlete basis=result team=1001 athlete=0 rank=1 points=10",
"key=e:1:a:201 scope=athlete basis=result team=1001 athlete=201 rank=1 points=10",
"key=e:1:a:202 scope=athlete basis=result team=1001 athlete=202 rank=1 points=10",
"key=e:2 scope=athlete basis=result team=1002 athlete=0 rank=2 points=8",
}
if got := rankedSummary(es.Ranked); strings.Join(got, "\n") != strings.Join(wantRanked, "\n") {
t.Fatalf("名次明细不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantRanked, "\n"))
}
// 流水:单位那一份照旧(count_to_team=true),队员的个人分是「只记个人」的额外流水——
// 它们 count_to_team=false,所以既不会让单位总分变成两份,也不会进单位奖牌。
wantLedger := []string{
"team=1001 athlete=0 entry=1 result=100 points=10 countToTeam=true",
"team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false",
"team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false",
"team=1002 athlete=0 entry=2 result=200 points=8 countToTeam=true",
}
if got := ledgerSummary(es.Ledger); strings.Join(got, "\n") != strings.Join(wantLedger, "\n") {
t.Fatalf("流水不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantLedger, "\n"))
}
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
// 单位总分只算一次:10 分(不是 30 分),金牌一枚(不是三枚)。
rows := BuildTeamStandings(in)
if len(rows) != 2 {
t.Fatalf("积分榜应有两行,得到 %+v", rows)
}
if rows[0].TeamID != 1001 || !nearlyEqual(rows[0].Points, 10) ||
rows[0].TeamOnlyPoints != 10 || rows[0].IndividualPoints != 0 || rows[0].Gold != 1 || rows[0].Medals != 1 {
t.Fatalf("双打单位那一行 = %+v,期望总分 10(仅单位 10、个人 0)、1 枚金牌(不进个人分)", rows[0])
}
if rows[1].TeamID != 1002 || rows[1].Points != 8 || rows[1].Silver != 1 {
t.Fatalf("对照单位那一行 = %+v,期望 8 分 1 银", rows[1])
}
medals := BuildMedalStandings(in)
if medals[0].TeamID != 1001 || medals[0].Gold != 1 || medals[0].Total != 1 {
t.Fatalf("单位奖牌榜第一行 = %+v,期望单位 1001 只记 1 枚金牌", medals[0])
}
// 个人榜:两个队员各一份满分、各一枚金牌。
athletes := BuildAthleteStandings(in)
wantAthletes := []string{
"athlete=201 team=1001 points=10 1/0/0 medals=1",
"athlete=202 team=1001 points=10 1/0/0 medals=1",
}
got := make([]string, 0, len(athletes))
for _, row := range athletes {
got = append(got, fmt.Sprintf("athlete=%d team=%d points=%v %d/%d/%d medals=%d",
row.AthleteID, row.TeamID, row.Points, row.Gold, row.Silver, row.Bronze, row.Medals))
}
// 两人同分同牌,排序落到姓名(队员一 < 队员二),顺序稳定。
if strings.Join(got, "\n") != strings.Join(wantAthletes, "\n") {
t.Fatalf("个人榜不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantAthletes, "\n"))
}
}
// 现场的双打口径(通道两个单位开关都关掉):队员照拿个人分与个人奖牌,单位一分一牌都不拿。
func TestTeamEntryMembersWithTeamFlagsOff(t *testing.T) {
entries, results := twoDoubles()
es := mustScore(t, doublesEventInput(entries, results, true))
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
for _, row := range BuildTeamStandings(in) {
if row.Points != 0 || row.Medals != 0 {
t.Errorf("单位 %d 在「都不计入」下应为 0 分 0 牌,得到 %+v", row.TeamID, row)
}
}
for _, row := range BuildMedalStandings(in) {
if row.Total != 0 {
t.Errorf("单位 %d 在「都不计入」下不该有奖牌,得到 %+v", row.TeamID, row)
}
}
athletes := BuildAthleteStandings(in)
if len(athletes) != 2 || athletes[0].Points != 10 || athletes[0].Gold != 1 ||
athletes[1].Points != 10 || athletes[1].Gold != 1 {
t.Fatalf("队员的个人分与个人奖牌照发,得到 %+v", athletes)
}
}
// 联队 + 队员同时存在(A+B 联队派两个人打双打):两套机制各管一维,不许互相污染——
// 单位那一份仍按份额**拆**(各 5 分)、奖牌各 1 枚;队员各拿**一整份**(各 10 分,不是各 5 分)。
func TestJointTeamWithMembersKeepsSharesAndCopiesSeparate(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, value: 11.2,
shares: []TeamShare{{TeamID: 1001, Weight: 1}, {TeamID: 1002, Weight: 1}},
members: []EntryAthlete{{AthleteID: 201, Name: "队员一"}, {AthleteID: 202, Name: "队员二"}}},
})
es := mustScore(t, doublesEventInput(entries, results, false))
// 流水:单位那两份各 5 分(按份额拆),队员那两份各 10 分(整份,只记个人)。
ledger := ledgerSummary(es.Ledger)
want := []string{
"team=1001 athlete=0 entry=1 result=100 points=5 countToTeam=true",
"team=1002 athlete=0 entry=1 result=100 points=5 countToTeam=true",
"team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false",
"team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false",
}
if strings.Join(ledger, "\n") != strings.Join(want, "\n") {
t.Fatalf("联队 + 队员的流水不对:\n得到 %s期望 %s", strings.Join(ledger, "\n"), strings.Join(want, "\n"))
}
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
for _, row := range BuildMedalStandings(in) {
if row.Gold != 1 || row.Total != 1 {
t.Errorf("联队单位 %d 应各记 1 枚金牌(队员不重复发牌),得到 %+v", row.TeamID, row)
}
}
athletes := BuildAthleteStandings(in)
if len(athletes) != 2 || athletes[0].Points != 10 || athletes[1].Points != 10 {
t.Fatalf("联队里的队员各拿一整份个人分,得到 %+v", athletes)
}
}
// 硬门槛:挂上队员之后,**单位维度逐字节不变**。
//
// 同一份成绩算两遍:一遍是升级前的老数据形态(只有姓名文字、认不出人),一遍是升级后
// 回填出队员身份。单位那一份名次、流水、总分、奖牌必须一模一样——队员的分只走个人维度,
// 绝不能渗进单位总分或单位奖牌(那是本单最容易错的地方)。
func TestTeamEntryMembersKeepTeamDimensionByteIdentical(t *testing.T) {
legacyEntries, legacyResults := twoDoublesLegacy()
without := mustScore(t, doublesEventInput(legacyEntries, legacyResults, false))
entries, results := twoDoubles()
with := mustScore(t, doublesEventInput(entries, results, false))
before := teamDimensionDump(without)
after := teamDimensionDump(with)
if before != after {
t.Fatalf("挂上队员后单位维度变了:\n之前\n%s之后\n%s", before, after)
}
if !strings.Contains(before, "单位榜") || !strings.Contains(before, "单位奖牌榜") {
t.Fatalf("对拍文本里必须有单位榜与单位奖牌榜,否则等于两份空文本相等:\n%s", before)
}
// 防空转:老形态的个人维度确实是空的(队员认不出来就上不了个人榜)。
if got := len(BuildAthleteStandings(StandingsInput{Ledger: without.Ledger, Ranked: without.Ranked})); got != 0 {
t.Fatalf("没有队员关联时个人榜应为空,得到 %d 行", got)
}
if got := len(BuildAthleteStandings(StandingsInput{Ledger: with.Ledger, Ranked: with.Ranked})); got != 2 {
t.Fatalf("挂上队员后个人榜应有两个队员,得到 %d 行", got)
}
}
// teamDimensionDump 把「单位维度」的全部事实拼成稳定文本:单位那一份流水、单位总分、
// 单位榜与单位奖牌榜。队员的个人流水不进这份文本(它不属于单位维度)。
func teamDimensionDump(es EventScore) string {
var lines []string
for _, l := range es.Ledger {
if l.AthleteID != 0 {
continue
}
lines = append(lines, fmt.Sprintf("流水 team=%d entry=%d result=%d points=%v countToTeam=%v",
l.TeamID, l.EntryID, l.ResultID, l.Points, l.CountToTeam))
}
for _, rr := range es.Ranked {
if rr.AthleteID != 0 {
continue
}
lines = append(lines, fmt.Sprintf("名次 key=%s scope=%s team=%d rank=%d points=%v shares=%s",
rr.SubjectKey, rr.Scope, rr.TeamID, rr.Rank, rr.Points, formatShares(rr.Shares)))
}
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
for _, weight := range []float64{1, 0.5} {
for _, exclude := range []bool{false, true} {
for _, row := range BuildTeamStandings(StandingsInput{
Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked,
IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude,
}) {
lines = append(lines, fmt.Sprintf("单位榜 w=%v ex=%v team=%d pts=%v only=%v indiv=%v %d/%d/%d",
weight, exclude, row.TeamID, row.Points, row.TeamOnlyPoints, row.IndividualPoints,
row.Gold, row.Silver, row.Bronze))
}
for _, row := range BuildMedalStandings(StandingsInput{
Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked,
IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude,
}) {
lines = append(lines, fmt.Sprintf("单位奖牌榜 w=%v ex=%v team=%d %d/%d/%d total=%d",
weight, exclude, row.TeamID, row.Gold, row.Silver, row.Bronze, row.Total))
}
}
}
return strings.Join(lines, "\n") + "\n"
}
// rankedSummary 打印名次明细里「谁、什么名次、多少分」这几件事。
func rankedSummary(ranked []RankedResult) []string {
out := make([]string, 0, len(ranked))
for _, rr := range ranked {
out = append(out, fmt.Sprintf("key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v",
rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points))
}
return out
}
// ledgerSummary 打印流水的归属与是否计入单位总分。
func ledgerSummary(ledger []LedgerEntry) []string {
out := make([]string, 0, len(ledger))
for _, l := range ledger {
out = append(out, fmt.Sprintf("team=%d athlete=%d entry=%d result=%d points=%v countToTeam=%v",
l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.Points, l.CountToTeam))
}
return out
}
+3 -1
View File
@@ -84,6 +84,8 @@ type participant struct {
state ResultState state ResultState
// shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。 // shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。
shares []TeamShare shares []TeamShare
// members 是团体报名的队员(团队项目的成员运动员);留空 = 只报单位(老数据)。
members []EntryAthlete
} }
func fixture(ps []participant) ([]Entry, []Result) { func fixture(ps []participant) ([]Entry, []Result) {
@@ -93,7 +95,7 @@ func fixture(ps []participant) ([]Entry, []Result) {
entries = append(entries, Entry{ entries = append(entries, Entry{
ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID, ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID,
AthleteID: p.athleteID, AthleteName: p.name, AthleteID: p.athleteID, AthleteName: p.name,
Status: p.entryStatus, TeamShares: p.shares, Status: p.entryStatus, TeamShares: p.shares, Athletes: p.members,
}) })
state := p.state state := p.state
if state == "" { if state == "" {
+96 -5
View File
@@ -69,6 +69,14 @@ type RankedResult struct {
AwardMedals bool AwardMedals bool
CountToMedals bool CountToMedals bool
CountToTeamMedals bool CountToTeamMedals bool
// MemberCopy 为 true 表示这条名次是「团体报名的队员各拿一份」的个人副本(devdocs/20):
// 分记到 AthleteID 头上(个人榜),但**不进单位维度**——流水恒为 count_to_team=false,
// 单位奖牌按报名项去重时也只认报名项那一份。
//
// 它只在「引擎产出 → 落库」这一段有意义:channel_ranks 不存这个开关,落库后认出这些行
// 靠 subject_key 的 'e:<报名项>:a:<运动员>' 形状与 athlete_id 关联,而下游(榜单、导出、
// 快照)本来就不需要它——它们看的是「按报名项去重」与 athlete_id 这两件既有事实。
MemberCopy bool
} }
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 // ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
@@ -430,12 +438,65 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro
for _, g := range groups { for _, g := range groups {
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category) pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
for _, c := range g.members { for _, c := range g.members {
out = append(out, rankedResult(in, ch, c, g.startRank, pts)) out = append(out, entryRankedResults(in, ch, c, g.startRank, pts)...)
} }
} }
return out return out
} }
// entryRankedResults 产出一条报名的**全部**名次明细:第一条是报名项自己那份(单位维度只认它,
// 老数据也只有这一条),后面是每个队员的个人副本(团队报名才有,每人一份完整的个人分)。
//
// 顺序是有意的:报名项那一份排在最前。channel_ranks 按 (…, 名次, id) 读出、奖牌去重按报名项
// 取同键里的第一条,于是「单位维度读到的是哪一份」与升级前完全一致。
func entryRankedResults(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) []RankedResult {
base := rankedResult(in, ch, c, rank, points)
members := memberAthletes(ch, c.entry)
if len(members) == 0 {
return []RankedResult{base}
}
out := make([]RankedResult, 0, len(members)+1)
out = append(out, base)
for _, m := range members {
out = append(out, memberCopy(base, m))
}
return out
}
// memberAthletes 判断这条名次要不要给队员各发一份:只有**个人通道**(scope=athlete)才发。
// 团体通道(scope=team)的名次本来就记在单位头上,队员不参与;带运动员的报名(个人报名)
// 自己就是那个人的身份,没有队员这回事。
func memberAthletes(ch ScoreChannel, e Entry) []EntryAthlete {
if ch.Scope != ScopeAthlete || e.AthleteID != 0 {
return nil
}
return e.Athletes
}
// memberCopy 把「报名项那一份名次」复制成某个队员的个人分:分数、名次、归属单位与份额全部照抄,
// 只把身份换成这个队员,并打上 MemberCopy 标记(落库时按它走「只记个人」的流水)。
//
// 照抄 TeamID 与 Shares 是**必须的**:单位维度按报名项去重(standings.medalKeyOf),
// 无论同键里的哪一份胜出,单位拿到的分与牌都是同一份;换成队员自己的单位或另造份额,
// 就会在「谁在去重里胜出」这件事上分叉,联队报名尤其容易给错单位记牌。
func memberCopy(base RankedResult, m EntryAthlete) RankedResult {
cp := base
cp.MemberCopy = true
cp.AthleteID = m.AthleteID
cp.AthleteName = m.Name
cp.SubjectKey = memberSubjectKey(base.EntryID, m.AthleteID)
return cp
}
// memberSubjectKey 是队员个人副本的主体标识:'e:<报名项>:a:<运动员>'。
//
// 必须带上报名项:同一个运动员可以同时出现在同组别的两条报名里(例如一个单位的一队与二队
// 各带了他),只用 'a:<运动员>' 会撞 channel_ranks 的 (通道, 组别, 主体) 唯一索引,
// 整次发布直接 500。既有名次键(e: / a: / t: / ch:)一个字符都没变,老行不受影响。
func memberSubjectKey(entryID, athleteID int64) string {
return fmt.Sprintf("e:%d:a:%d", entryID, athleteID)
}
// groupPoints 计算一组并列者拿到的分。 // groupPoints 计算一组并列者拿到的分。
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1; // 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1;
// lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。 // lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。
@@ -684,6 +745,9 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
// 0 分不产生流水,避免流水表被零分噪声撑大。 // 0 分不产生流水,避免流水表被零分噪声撑大。
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。 // 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
// //
// 队员的个人副本走另一条路(memberLedger):每个队员一整份分、只记个人(count_to_team=false),
// 于是单位总分与单位奖牌一个字节都不动——那是本单最容易错的地方,见 memberLedger。
//
// 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0), // 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0),
// 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id, // 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id,
// 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」 // 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」
@@ -694,10 +758,6 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
if rr.Points == 0 { if rr.Points == 0 {
continue continue
} }
athleteID := int64(0)
if rr.Scope == ScopeAthlete {
athleteID = rr.AthleteID
}
sourceID := rr.ResultID sourceID := rr.ResultID
if sourceID == 0 { if sourceID == 0 {
sourceID = rr.EntryID sourceID = rr.EntryID
@@ -705,6 +765,14 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
if sourceID == 0 { if sourceID == 0 {
sourceID = ch.ID sourceID = ch.ID
} }
if rr.MemberCopy {
out = append(out, memberLedger(rr, in, ch, sourceID))
continue
}
athleteID := int64(0)
if rr.Scope == ScopeAthlete {
athleteID = rr.AthleteID
}
split := splitSharePoints(rr.Points, rr.Shares) split := splitSharePoints(rr.Points, rr.Shares)
for i, s := range rr.Shares { for i, s := range rr.Shares {
if split[i] == 0 { if split[i] == 0 {
@@ -728,6 +796,29 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
return out return out
} }
// memberLedger 是队员个人副本的流水:**只记个人**(CountToTeam 恒为 false),
// 每个队员一整份分,不按份额拆(联队那套是「拆」,这里是「复制」,别抄混)。
//
// 为什么恒为 false、而不是照抄通道开关:单位维度只认报名项那一份流水(它照抄通道开关),
// 队员这两份若也计入,单位总分就会变成 N 份(双打就是双倍),单位奖牌同理。
// SumByTeam 遇到 count_to_team=0 直接跳过,所以单位总分、单位明细、单位奖牌全都不会看到它们;
// 而 SumByAthlete 不看这个开关,队员的个人分照拿(这也正是「双打不进单位、但上个人榜」的实现)。
func memberLedger(rr RankedResult, in EventScoreInput, ch ScoreChannel, sourceID int64) LedgerEntry {
return LedgerEntry{
TeamID: rr.TeamID,
AthleteID: rr.AthleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: rr.EntryID,
ResultID: rr.ResultID,
ChannelID: ch.ID,
SourceType: SourceResult,
SourceID: sourceID,
Points: rr.Points,
CountToTeam: false,
}
}
// subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径): // subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径):
// 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位), // 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位),
// 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。 // 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。
+32 -5
View File
@@ -298,9 +298,12 @@ func medalKeyOf(rr RankedResult, index int) medalKey {
return k return k
} }
// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细去重成「每个主体一条」: // pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细按 keyOf 去重成「每个主体一条」:
// 只保留优先级最高的通道名次(个人通道优先),否则双通道项目一条成绩会拿两枚奖牌。 // 只保留优先级最高的通道名次(个人通道优先),否则双通道项目一条成绩会拿两枚奖牌。
func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult { //
// 去重键由调用方给:**单位维度按报名项**(medalKeyOf,一条报名只记一枚牌),
// **个人维度按报名项 + 运动员**(athleteMedalKeyOf,团体报名的队员各记各的)。
func pickMedalChannels(ranked []RankedResult, excludeTeam bool, keyOf func(RankedResult, int) medalKey) []RankedResult {
best := make(map[medalKey]RankedResult, len(ranked)) best := make(map[medalKey]RankedResult, len(ranked))
order := make([]medalKey, 0, len(ranked)) order := make([]medalKey, 0, len(ranked))
for i, rr := range ranked { for i, rr := range ranked {
@@ -310,7 +313,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
if excludeTeam && rr.Scope == ScopeTeam { if excludeTeam && rr.Scope == ScopeTeam {
continue continue
} }
k := medalKeyOf(rr, i) k := keyOf(rr, i)
cur, ok := best[k] cur, ok := best[k]
if !ok { if !ok {
best[k] = rr best[k] = rr
@@ -328,10 +331,31 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
return out return out
} }
// athleteMedalKeyOf 是个人维度的奖牌去重键:在单位维度的键上再带上运动员。
//
// 团体报名的队员各有一份个人副本,它们与报名项那一份共享 entry_id——只按报名项去重的话,
// 三份会并成一条(且胜出的是排在最前的报名项那份,AthleteID=0,个人维度根本不看它),
// 队员的个人奖牌会**一枚都发不出来**。带上运动员之后:同一报名项同一运动员仍旧只记一枚
// (多通道项目不会重复发牌),不同队员各记各的。
//
// 只对「有报名项」的名次加这一维:没有报名项的聚合名次本来就用主体区分(medalKeyOf 已处理),
// 老数据的名次构不成同一个键下的多条,行为逐字节不变。
func athleteMedalKeyOf(rr RankedResult, index int) medalKey {
k := medalKeyOf(rr, index)
if k.entryID != 0 && rr.AthleteID != 0 {
k.scope, k.subject = ScopeAthlete, rr.AthleteID
}
return k
}
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:每个份额各记**一枚整牌**, // teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:每个份额各记**一枚整牌**,
// 联队拿到的金牌因此两个成员单位各记 1 枚(不是各 0.5 枚,口径见 TeamShare.Medal)。 // 联队拿到的金牌因此两个成员单位各记 1 枚(不是各 0.5 枚,口径见 TeamShare.Medal)。
// 份额里的 Medal 显式关掉时该单位不记这枚牌(积分份额 Weight 不参与奖牌计算)。 // 份额里的 Medal 显式关掉时该单位不记这枚牌(积分份额 Weight 不参与奖牌计算)。
// //
// 去重按**报名项**(medalKeyOf):一条报名在同一小项同一组别只记一枚牌,而团体报名的队员
// 个人副本与报名项那一份共享 entry_id,因此**不会把单位奖牌数放大成队员人数倍**
// ——这是本单最容易错的地方,由 TestTeamEntryMembersKeepTeamDimensionByteIdentical 钉住。
//
// 两层开关(devdocs 17 §4.5): // 两层开关(devdocs 17 §4.5):
// - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜; // - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜;
// - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。 // - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。
@@ -339,7 +363,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
// 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。 // 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。
func teamMedals(in StandingsInput) map[int64]medalTally { func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally) out := make(map[int64]medalTally)
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) { for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals, medalKeyOf) {
if rr.Scope != ScopeTeam && !rr.CountToTeamMedals { if rr.Scope != ScopeTeam && !rr.CountToTeamMedals {
continue continue
} }
@@ -368,9 +392,12 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
// athleteMedals 只统计个人项目(scope=athlete)的奖牌; // athleteMedals 只统计个人项目(scope=athlete)的奖牌;
// 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。 // 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。
//
// 去重按**报名项 + 运动员**(athleteMedalKeyOf):团体报名的每个队员各记一枚自己的奖牌
// (他们共享同一条报名项,只按报名项去重会把队员的奖牌并成一枚、甚至一枚都不发)。
func athleteMedals(in StandingsInput) map[int64]medalTally { func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally) out := make(map[int64]medalTally)
for _, rr := range pickMedalChannels(in.Ranked, false) { for _, rr := range pickMedalChannels(in.Ranked, false, athleteMedalKeyOf) {
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 { if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
continue continue
} }
+16
View File
@@ -311,6 +311,18 @@ func (s TeamShare) CountsMedal() bool {
return s.Medal == nil || *s.Medal return s.Medal == nil || *s.Medal
} }
// EntryAthlete 是团体报名里的一名队员(团队成员),身份靠运动员档案(AthleteID)确立;
// 人工认人时以**号码布**为准(姓名会重名,号码布不会,见迁移 0014 与 devdocs/20)。
//
// 与 TeamShare 对称但语义**相反**(devdocs/20 §二):TeamShare 是「一条报名 → 多个单位、
// 按权重**拆**分」,EntryAthlete 是「一条报名 → 多个运动员、每人各拿**整份**分」。
// 抄联队那套「拆」会把双打两个人的分各砍一半,这正是本单最容易抄错的地方。
type EntryAthlete struct {
AthleteID int64 `json:"athleteId"`
// Name 是冗余姓名,与 Entry.AthleteName 同一个用途:引擎不必回查库就能产出个人榜的那一行。
Name string `json:"name,omitempty"`
}
// Entry 报名项:某单位派某人/某队参加某小项。TeamID 是**主单位**(联队时也保留它, // Entry 报名项:某单位派某人/某队参加某小项。TeamID 是**主单位**(联队时也保留它,
// 报名项的唯一键、榜单兜底、导入去重都还认这一个),联队的其余成员单位在 TeamShares 里。 // 报名项的唯一键、榜单兜底、导入去重都还认这一个),联队的其余成员单位在 TeamShares 里。
type Entry struct { type Entry struct {
@@ -328,6 +340,10 @@ type Entry struct {
// TeamShares 是这条报名的归属份额配置:空 = 主单位独占 100%(老数据、普通报名), // TeamShares 是这条报名的归属份额配置:空 = 主单位独占 100%(老数据、普通报名),
// 多条 = 联队。引擎只读不改,取分的唯一入口是 ResolveShares。 // 多条 = 联队。引擎只读不改,取分的唯一入口是 ResolveShares。
TeamShares []TeamShare `json:"teamShares,omitempty"` TeamShares []TeamShare `json:"teamShares,omitempty"`
// Athletes 是这条报名的队员(团队 / 对抗项目的成员),空 = 只报单位(升级前的老数据、
// 以及「人没录档案只登记姓名」的降级形态)。只对纯团体报名(AthleteID == 0)有意义。
// 队员各拿一份完整的个人分与个人奖牌;单位维度仍然只算这条报名一次。
Athletes []EntryAthlete `json:"athletes,omitempty"`
} }
// Result 成绩;一条报名项一条成绩。 // Result 成绩;一条报名项一条成绩。
+6 -1
View File
@@ -128,6 +128,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
if err != nil { if err != nil {
return EntriesWorkbook{}, err return EntriesWorkbook{}, err
} }
// 队员关联(devdocs/20):整份名单读一次,逐行按 entry_id 取。
members, err := loadEntryMembers(db)
if err != nil {
return EntriesWorkbook{}, err
}
query := ` query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''), SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
@@ -206,7 +211,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil { &row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err) return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
} }
row.Name = participantName(athleteName, memberJSON) row.Name = participantLabel(athleteName, memberJSON, members[entryID])
if heat.Valid { if heat.Valid {
row.Heat = strconv.FormatInt(heat.Int64, 10) row.Heat = strconv.FormatInt(heat.Int64, 10)
} }
+143
View File
@@ -0,0 +1,143 @@
package export
import (
"database/sql"
"fmt"
"strings"
)
// 本文件是「团体报名的队员」(devdocs/20)在导出侧的读法:一条团体报名可以关联多个运动员
// (`entry_athletes`),导出件上要看得出队里坐着谁。
//
// 写法是 `姓名(号码布)`:号码布是运动员的唯一标识(迁移 0014、devdocs/20 §二),
// 光写名字遇到重名就认不出是谁。多个队员用顿号连起来——与 member_names 原有的写法同形,
// 拿导出件对账的人不必为两种名单学两套读法。
//
// member_names **不因为挂了队员而消失**:它是显示用的文字名单,里面没被队员认走的那些名字
// (人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。同一个口径也用在
// 大屏侧(web/src/display/members.ts),两处必须一致,否则同一场比赛的同一支队伍
// 在导出件与屏幕上会写成两份名单。
//
// 队员**不改每一行的高度与条数**:一条报名仍然只占一行(护栏见 entry_members_rows_test.go),
// 队员只让这一格更长。
// entryMember 是报名里一名队员的展示字段。身份是 athlete_id(引擎按它发个人分与个人奖牌),
// 这里只读展示需要的两列:姓名与号码布。
type entryMember struct {
Name string
Bib string
}
// entryMembersReady 判断队员表在不在。
//
// 表不在(库还没跑到 0026)= 库里没有任何队员关联 = 只报单位,与升级前一致,
// 所以按「没有队员」处理是**精确**的,不是容错降级(与 entrySharesReady 同一判据)。
func entryMembersReady(db *sql.DB) (bool, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
return false, fmt.Errorf("export: 查询报名队员表: %w", err)
}
return n != 0, nil
}
// loadEntryMembers 读全库的队员关联,按报名 id 归组。
//
// 顺序按 sort_order(写侧按请求给的顺序编号,老数据按文字名单的书写顺序回填):
// 展示顺序稳定,导出件不会因为主键顺序而变。各导出件都读一次给全部工作表复用——
// 逐张表去查是白跑几十遍(与 loadJointTeams 同一个取舍)。
func loadEntryMembers(db *sql.DB) (map[int64][]entryMember, error) {
ready, err := entryMembersReady(db)
if err != nil {
return nil, err
}
if !ready {
return nil, nil
}
rows, err := db.Query(`
SELECT ea.entry_id, COALESCE(a.name, ''), COALESCE(a.bib, '')
FROM entry_athletes ea
JOIN athletes a ON a.id = ea.athlete_id
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名队员: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]entryMember)
for rows.Next() {
var (
entryID int64
m entryMember
)
if err := rows.Scan(&entryID, &m.Name, &m.Bib); err != nil {
return nil, fmt.Errorf("export: 扫描报名队员: %w", err)
}
out[entryID] = append(out[entryID], m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名队员: %w", err)
}
return out, nil
}
// memberLabel 把一名队员写成导出件上的字:`张伟(A001)`。
//
// 姓名或号码布缺一个时只写有的那个(不留「()」这种空壳);两个都空的人在名单里一个字都不写。
func memberLabel(m entryMember) string {
name := strings.TrimSpace(m.Name)
bib := strings.TrimSpace(m.Bib)
switch {
case name != "" && bib != "":
return name + "(" + bib + ")"
case name != "":
return name
default:
return bib
}
}
// coveredByMember 判断文字名单里的一个名字是不是已经由某名队员写过了。
//
// 判据与迁移 0026 的回填同源:文字名单里写的可能是**姓名**,也可能是**号码布**
// (导入规范允许「队员名单」列填号码布)。认走之后不再重复写一遍,否则一个队员在导出件上
// 会出现两次(「王五(A001)、王五」这种)。
func coveredByMember(name string, members []entryMember) bool {
for _, m := range members {
if name == strings.TrimSpace(m.Name) || name == strings.TrimSpace(m.Bib) {
return true
}
}
return false
}
// participantLabel 是报名名单 / 分项成绩表 / 全部成绩单页 / 单位奖牌明细共用的
// 「姓名」「运动员 / 队」列取值。
//
// 没有队员关联时**逐字**等于 participantName:个人写姓名、团体写文字名单,一个字都不多
// ——老数据(含迁移按文字名单回填不上人的那些)的导出件不许有任何变化。
func participantLabel(athleteName, memberJSON string, members []entryMember) string {
if len(members) == 0 {
return participantName(athleteName, memberJSON)
}
out := make([]string, 0, len(members)+1)
for _, m := range members {
if label := memberLabel(m); label != "" {
out = append(out, label)
}
}
seen := make(map[string]bool)
for _, name := range memberNamesOf(memberJSON) {
name = strings.TrimSpace(name)
if name == "" || seen[name] || coveredByMember(name, members) {
continue
}
seen[name] = true
out = append(out, name)
}
if len(out) == 0 {
// 队员既没姓名也没号码布、文字名单也是空的:退回原来的取值,不把这一格写空。
return participantName(athleteName, memberJSON)
}
return strings.Join(out, "、")
}
@@ -0,0 +1,67 @@
package export
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 本文件是「没有队员关联的报名,导出件逐行一致」这条硬门槛的对拍工具(devdocs/20 的导出侧)。
//
// 做法与 squad_dump_test.go 完全一致:把每一份导出件的每一张工作表、每一个单元格 dump 成文本
// (`工作簿|工作表|行|列|值`),先落盘到 EXPORT_MEMBERS_DUMP 指定的路径(不设时写 t.TempDir(),
// 平时只当一次「全部导出件都能加载」的冒烟)。把**同一个文件**放到改动前的代码上再跑一次
// (对拍目录里的那份树只补这个用例、不补源码改动),两份 dump `diff` 必须为空。
//
// 为什么这份数据里没有 entry_athletes 行:老库升级后绝大多数报名都没有队员关联(只报单位的
// 团体报名、个人报名、只登记文字名单的报名),本单不许让它们的导出件发生任何变化——包括
// 因为「多查了一张表」「多拼了一个空串」而变的那一种。挂上队员之后的变化由下面的灵敏度自检盯着。
// TestMemberlessExportDumpIsUnchanged 见文件头。数据集复用 seedSquadDumpEvent:它覆盖了
// 个人小项(含团体行回落成员名单)、纯团体报名、联队份额与队名,且**一条 entry_athletes 都没有**。
func TestMemberlessExportDumpIsUnchanged(t *testing.T) {
db := openEventDB(t)
seedSquadDumpEvent(t, db)
opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}}
memberless := dumpAllWorkbooks(t, db, opts)
// 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。
path := os.Getenv("EXPORT_MEMBERS_DUMP")
if path == "" {
path = filepath.Join(t.TempDir(), "members-empty-dump.txt")
}
if err := os.WriteFile(path, []byte(memberless), 0o644); err != nil {
t.Fatalf("写出 dump: %v", err)
}
t.Logf("无队员 dump:%s(%d 个单元格)", path, strings.Count(memberless, "\n"))
// 灵敏度自检:给双打那条报名挂上一名队员(另一名仍然是文字名单里的人),重新 dump,两次必须不同。
// 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到队员列——那就白对拍了。
// 在对拍用的旧代码上这条自检本来就会红(旧代码根本不读 entry_athletes),正说明两份 dump 的
// 差异只可能来自这次改动。
stmts := []string{
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A001')`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
SELECT e.id, a.id, 0 FROM entries e, athletes a
WHERE e.event_id = 2 AND e.member_names LIKE '%王磊%' AND a.name = '王磊'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
withMembers := dumpAllWorkbooks(t, db, opts)
if withMembers == memberless {
t.Fatal("给报名挂上队员后 dump 没变:这份对拍看不见队员列,对拍结论无效")
}
// 关联上的写「姓名(号码布)」,没关联上的文字名字接在后面(人没录档案的临时队员照旧在名单里)。
if !strings.Contains(withMembers, "王磊(A001)、赵雷") {
t.Fatal("挂队员后的 dump 里找不到「王磊(A001)、赵雷」:队员没有按「姓名(号码布)」写进导出件")
}
if strings.Contains(memberless, "(A001)") {
t.Fatal("无队员的 dump 里不该出现号码布写法")
}
}
@@ -0,0 +1,71 @@
package export
import (
"database/sql"
"testing"
)
// 本文件钉住「团体报名挂上队员之后,成绩表里这条报名仍然只占一行」(devdocs/20 的导出侧边界)。
//
// 原因:一条报名在同一通道下会有 1 + N 行 channel_ranks(报名项自己那份 + 每个队员一份个人副本),
// 成绩表按 (通道, 报名, 组别) JOIN 名次时会把这条报名印 N+1 遍——现场看到的就是「双打报了三次」。
// 名次取「报名项那一份」(最小 id),值与升级前一致。
func TestEventRowsKeepOneRowPerEntryWithMembers(t *testing.T) {
db := openEventDB(t)
seedEntryWithMembers(t, db)
rows, err := loadEventRows(db, 1, 0, true, map[int64]jointTeam{})
if err != nil {
t.Fatalf("读成绩行: %v", err)
}
if len(rows) != 1 {
t.Fatalf("成绩表应只有一条报名那一行,得到 %d 行:%+v", len(rows), rows)
}
if rows[0].Rank != "1" {
t.Errorf("名次应取报名项那一份(第 1 名),得到 %q", rows[0].Rank)
}
// 运动员列写队员的「姓名(号码布)」:号码布是唯一标识,导出侧要认得出是谁上了场
// (写法与理由见 entry_members.go)。这条报名仍然只占这一行——挂队员不许把行展开。
if rows[0].Participant != "王五(A001)、赵六(A002)" {
t.Errorf("队员名单应写成「姓名(号码布)」,得到 %q", rows[0].Participant)
}
}
// seedEntryWithMembers 造一条「双打」团体报名:队员关联两条,channel_ranks 里三条名次
// (报名项 + 两个队员的个人副本),与发布后的真实落库形状一致。
func seedEntryWithMembers(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '羽毛球双打', 'team', 'auto', 'count', 'desc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, member_names)
VALUES (1, 1, 1, '["王五","赵六"]')`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 2, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 1, 1, '21', 21, '分', 'published')`,
// 报名项那一份(先落库,id 最小)+ 两个队员的个人副本。
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:1', 1, 1, 1, 1, 10)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:2', 1, 1, 2, 1, 10)`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
@@ -0,0 +1,263 @@
package export
import (
"database/sql"
"path/filepath"
"testing"
)
// 本文件钉住导出侧的「团体报名的队员」(devdocs/20):报名名单的「姓名」列、分项成绩表 /
// 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列,都要看得出这条报名里坐着谁。
//
// 写法是 `姓名(号码布)`(断言里的字面量就是导出件上那一格的字):号码布是运动员的唯一标识
// (迁移 0014、devdocs/20 §二),光写名字遇到重名认不出人。多个队员用顿号连起来——与
// member_names 原有的写法同形,读者不必为两种名单学两套读法。
//
// member_names **不因为挂了队员而消失**:它是文字名单,里面没被队员关联认走的那些名字
// (人没录档案、临时借调的)照旧接在后面。两种情况都要能一眼看出谁上了场。
//
// 一条报名仍然只占一行(护栏在 entry_members_rows_test.go):队员只让这一行更长,
// 不把报名展开成 N+1 行。
// seedDoublesMembers 造一个「双打」小项,三种报名形态各一条:
// - 挂了队员的团体报名(文字名单里还多一个没录档案的名字);
// - 只有文字名单的团体报名(升级前的老形状,用来钉住「没有队员关联时逐字不变」);
// - seedEvent 的个人小项(100 米跑),个人报名的姓名一个字都不该变。
//
// channel_ranks 与流水的形状与发布后的真实落库一致:一条报名 1 + N 行名次(报名项自己那份
// 排在 id 最小的位置 + 每个队员一份个人副本,subject_key 是 'e:<报名项>:a:<运动员>'),
// 队员那两份流水恒 count_to_team=0(只进个人榜,不进单位总分)。
func seedDoublesMembers(t *testing.T, db *sql.DB) {
t.Helper()
seedEvent(t, db) // T1/T2、张三 / 李四、100 米跑(两条已发布成绩与流水)
stmts := []string{
`INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
// 组别 id 是 2(seedEvent 的 100 米跑已经占了 id 1):报名与名次都必须挂在它名下,
// 否则导出按 (小项, 组别) 取成绩时一条都取不到。
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
// 双打配的是个人通道(队长口径:上个人积分榜);通道的 count_to_team 由现场在通道里配,
// 这里留默认 1——单位维度只算这条报名一次,与通道开关是两件事(见 domain.memberLedger)。
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (2, 2, 1, '["王五","赵六","临时借调的老周"]', 'registered')`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`,
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 4, 1)`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (2, 2, 2, '["孙七","周八"]', 'registered')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (2, 3, 1, '21', 21, 'pts', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (2, 4, 2, '18', 18, 'pts', 'published')`,
// 报名项那一份先落(id 最小 = 单位维度与成绩表读到的那一份),后面是队员的个人副本。
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, team_id, rank, points)
VALUES (2, 2, 2, 'result', 'athlete', 'e:3', 3, 3, 1, 1, 10)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:3', 3, 3, 3, 1, 1, 10)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:4', 3, 3, 4, 1, 1, 10)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, team_id, rank, points)
VALUES (2, 2, 2, 'result', 'athlete', 'e:4', 4, 4, 2, 2, 8)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
points, count_to_team)
VALUES (1, 2, 3, 3, 'result', 3, 10, 1)`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
source_id, points, count_to_team)
VALUES (1, 3, 2, 3, 3, 'result', 3, 10, 0)`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
source_id, points, count_to_team)
VALUES (1, 4, 2, 3, 3, 'result', 3, 10, 0)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
points, count_to_team)
VALUES (2, 2, 4, 4, 'result', 4, 8, 1)`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 报名名单:「姓名」列写队员的「姓名(号码布)」,文字名单里没被认走的那个名字接在后面;
// 没有队员关联的报名(老形状)一个字都不变。
func TestEntriesWorkbookShowsEntryMembers(t *testing.T) {
db := openEventDB(t)
seedDoublesMembers(t, db)
wb, err := LoadEntriesWorkbook(db, EntriesFilter{EventID: 2}, Options{})
if err != nil {
t.Fatalf("读报名名单: %v", err)
}
if len(wb.Rows) != 2 {
t.Fatalf("双打应有 2 条报名,得到 %d 条:%+v", len(wb.Rows), wb.Rows)
}
if got := wb.Rows[0].Name; got != "王五(A001)、赵六(A002)、临时借调的老周" {
t.Errorf("挂队员那行的姓名 = %q,期望 王五(A001)、赵六(A002)、临时借调的老周", got)
}
if got := wb.Rows[1].Name; got != "孙七、周八" {
t.Errorf("没挂队员那行的姓名 = %q,期望 孙七、周八(与升级前逐字一致)", got)
}
// 导出件本身(落盘那份与响应体同源)也要是这一格的字。
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows(entriesSheet)
if err != nil {
t.Fatalf("读取报名名单工作表: %v", err)
}
// 4 行版式 + 4 条报名(个人两条 + 双打两条);姓名是第 6 列。
names := make([]string, 0, len(rows))
for i := 4; i < len(rows); i++ {
names = append(names, cell(rows, i, 5))
}
want := []string{"张三", "李四", "王五(A001)、赵六(A002)、临时借调的老周", "孙七、周八"}
if !equalStrings(names, want) {
t.Errorf("报名名单姓名列 = %v,期望 %v", names, want)
}
}
// 分项成绩表与全部成绩单页的「运动员/队」列:队员带号码布写全,且**一条报名只占一行**。
func TestEventWorkbookShowsEntryMembers(t *testing.T) {
db := openEventDB(t)
seedDoublesMembers(t, db)
wb, err := LoadEventWorkbook(db, 2, Options{})
if err != nil {
t.Fatalf("读分项成绩: %v", err)
}
if len(wb.Sheets) != 1 {
t.Fatalf("双打只有 1 个组别,得到 %d 张表", len(wb.Sheets))
}
rows := wb.Sheets[0].Rows
if len(rows) != 2 {
t.Fatalf("成绩表应只有 2 条报名那两行,得到 %d 行:%+v", len(rows), rows)
}
if rows[0].Participant != "王五(A001)、赵六(A002)、临时借调的老周" {
t.Errorf("第 1 名运动员/队 = %q,期望队员带号码布写全", rows[0].Participant)
}
if rows[0].Rank != "1" {
t.Errorf("第 1 名名次 = %q,期望 1(取报名项那一份名次)", rows[0].Rank)
}
if rows[1].Participant != "孙七、周八" {
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", rows[1].Participant)
}
flat, err := LoadFlatWorkbook(db, Options{})
if err != nil {
t.Fatalf("读全部成绩单页: %v", err)
}
var found bool
for _, sh := range flat.Sheets {
for _, r := range sh.Rows {
if r.Participant == "王五(A001)、赵六(A002)、临时借调的老周" {
found = true
}
}
}
if !found {
t.Error("全部成绩单页里找不到队员带号码布的写法")
}
}
// 单位奖牌明细的「运动员/队」列:这块奖牌是哪支队拿的,队员照样写全。
func TestMedalDetailShowsEntryMembers(t *testing.T) {
db := openEventDB(t)
seedDoublesMembers(t, db)
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读单位奖牌明细: %v", err)
}
var participants []string
for _, sheet := range wb.Sheets {
for _, r := range sheet.Rows {
if r.EventName == "男子双打" {
participants = append(participants, r.Participant)
}
}
}
if len(participants) != 2 {
t.Fatalf("两块奖牌各一行,得到 %d 行:%v", len(participants), participants)
}
if participants[0] != "王五(A001)、赵六(A002)、临时借调的老周" {
t.Errorf("双打金牌那行 = %q,期望队员带号码布写全", participants[0])
}
if participants[1] != "孙七、周八" {
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", participants[1])
}
}
// 个人得分明细:一条双打报名在个人维度上是**每人一行、每人一整份**(不是一行两个人、
// 也不是一行半份)。这是主人定的口径(devdocs/20 §二):每个队员各拿一份完整的个人分,
// 于是两个人都能上个人积分榜——导出件上就该是这样两行。
//
// 名单里没关联上人的名字(临时借调的)**不产生个人行**:他上不了个人榜,导出件也不该凭空给他记分。
func TestAthleteDetailGivesEachMemberAFullCopy(t *testing.T) {
db := openEventDB(t)
seedDoublesMembers(t, db)
wb, err := LoadAthleteDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读个人得分明细: %v", err)
}
byName := make(map[string][]AthleteDetailRow)
for _, sheet := range wb.Sheets {
for _, r := range sheet.Rows {
byName[r.AthleteName] = append(byName[r.AthleteName], r)
}
}
for _, name := range []string{"王五", "赵六"} {
rows := byName[name]
if len(rows) != 1 {
t.Fatalf("%s 应有 1 行个人明细,得到 %d 行:%+v", name, len(rows), rows)
}
if rows[0].EventName != "男子双打" || rows[0].Points != 10 || rows[0].Rank != 1 {
t.Errorf("%s 的个人明细 = %+v,期望 男子双打 / 第 1 名 / 10 分(一整份,不拆)", name, rows[0])
}
}
for _, name := range []string{"孙七", "周八", "临时借调的老周"} {
if rows := byName[name]; len(rows) != 0 {
t.Errorf("%s 没有个人关联 / 没有个人流水,不该出现在个人明细里,得到 %+v", name, rows)
}
}
}
// 单位积分明细:队员的个人副本恒 count_to_team=0,所以这条报名在单位维度上**只算一次**——
// 双打不会让单位的总分变成两份,单位明细里也只有报名项那一行。
func TestTeamDetailCountsEntryOnceWithMembers(t *testing.T) {
db := openEventDB(t)
seedDoublesMembers(t, db)
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读单位积分明细: %v", err)
}
var doubles []TeamDetailRow
for _, r := range wb.Rows {
if r.EventName == "男子双打" {
doubles = append(doubles, r)
}
}
if len(doubles) != 1 {
t.Fatalf("双打在单位明细里应只有 1 行(队员的副本不进单位总分),得到 %d 行:%+v", len(doubles), doubles)
}
if doubles[0].Points != 10 {
t.Errorf("单位实得分 = %v,期望 10(只算报名项那一份,不是 30)", doubles[0].Points)
}
if doubles[0].AthleteName != "" {
t.Errorf("单位明细的运动员列 = %q,期望留空(这一分记在单位头上)", doubles[0].AthleteName)
}
}
+48 -13
View File
@@ -431,18 +431,22 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
} }
sheets := make([]EventSheet, 0, len(events)) sheets := make([]EventSheet, 0, len(events))
// 联队配置读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。 // 联队配置与队员关联各读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。
joints, err := loadJointTeams(db) joints, err := loadJointTeams(db)
if err != nil { if err != nil {
return EventsWorkbook{}, err return EventsWorkbook{}, err
} }
members, err := loadEntryMembers(db)
if err != nil {
return EventsWorkbook{}, err
}
for _, ev := range events { for _, ev := range events {
cats, err := loadCategories(db, ev.id) cats, err := loadCategories(db, ev.id)
if err != nil { if err != nil {
return EventsWorkbook{}, err return EventsWorkbook{}, err
} }
if len(cats) == 0 { if len(cats) == 0 {
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts, joints) rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members)
if err != nil { if err != nil {
return EventsWorkbook{}, err return EventsWorkbook{}, err
} }
@@ -450,7 +454,7 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
continue continue
} }
for _, cat := range cats { for _, cat := range cats {
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts, joints) rows, err := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members)
if err != nil { if err != nil {
return EventsWorkbook{}, err return EventsWorkbook{}, err
} }
@@ -529,12 +533,30 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); // includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。 // 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
//
// 队员关联在这里自己读一次。LoadEventWorkbook 逐张小项 / 组别表地调它,所以那边走的是
// loadEventRowsWithMembers(读一次给全部表复用),这条只是给单表调用与既有用例留的入口。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) { func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) {
members, err := loadEntryMembers(db)
if err != nil {
return nil, err
}
return loadEventRowsWithMembers(db, eventID, categoryID, includeDrafts, joints, members)
}
// loadEventRowsWithMembers 是 loadEventRows 的本体:队员关联由调用方读好传进来。
func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam, members map[int64][]entryMember) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次, // 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。 // 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。 // 落不了库)回落到 results.rank,保持名次列不空白。
// //
// 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN:
// 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份
// 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。
// 取最小 id = 报名项那一份(引擎先产出它),名次值与升级前完全一致;
// 老数据每 (通道, 组别, 报名项) 最多一行,两种写法逐字节等价。
//
// gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源: // gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源:
// 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩), // 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩),
// 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。 // 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。
@@ -559,8 +581,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
WHERE c2.event_id = en.event_id WHERE c2.event_id = en.event_id
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
LIMIT 1) LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id LEFT JOIN channel_ranks cr ON cr.id = (
AND cr.category_id = en.category_id SELECT MIN(cr2.id) FROM channel_ranks cr2
WHERE cr2.channel_id = mc.id AND cr2.entry_id = en.id AND cr2.category_id = en.category_id)
LEFT JOIN ( LEFT JOIN (
SELECT e2.event_id, e2.category_id, COUNT(*) AS required, SELECT e2.event_id, e2.category_id, COUNT(*) AS required,
SUM(CASE WHEN r2.state = 'published' SUM(CASE WHEN r2.state = 'published'
@@ -608,7 +631,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil { &entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err) return nil, fmt.Errorf("export: 扫描成绩: %w", err)
} }
row.Participant = participantName(athleteName, memberJSON) row.Participant = participantLabel(athleteName, memberJSON, members[entryID])
row.Rank = rankLabel(rank, isTie != 0) row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat) row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote) row.Remark = joinRemark(entryRemark, resultNote)
@@ -931,13 +954,25 @@ func participantName(athleteName, memberJSON string) string {
if strings.TrimSpace(athleteName) != "" { if strings.TrimSpace(athleteName) != "" {
return athleteName return athleteName
} }
memberJSON = strings.TrimSpace(memberJSON) if names := memberNamesOf(memberJSON); names != nil {
if memberJSON == "" {
return ""
}
var names []string
if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 {
return strings.Join(names, "、") return strings.Join(names, "、")
} }
return memberJSON // 空值或坏 JSON:原样写出来(历史上 member_names 被手工写成别的样子也不能丢字)。
return strings.TrimSpace(memberJSON)
}
// memberNamesOf 解析 member_names(库里是 JSON 数组文本);空值、坏 JSON、空数组都返回 nil。
//
// **每一项原样返回**(不去空白、不丢空串):它直接写进老数据的导出件,改动它等于改动老导出件
// 的每一个字。新的队员路径在合并文字名单时自己决定怎么去空白(见 participantLabel)。
func memberNamesOf(memberJSON string) []string {
memberJSON = strings.TrimSpace(memberJSON)
if memberJSON == "" {
return nil
}
var raw []string
if err := json.Unmarshal([]byte(memberJSON), &raw); err != nil || len(raw) == 0 {
return nil
}
return raw
} }
+13 -5
View File
@@ -200,7 +200,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
} }
excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false") excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false")
hits := medalHits(ranked, excludeTeam) hits := medalHits(ranked, excludeTeam)
ctx, err := loadMedalContext(db) // 队员关联读一次给全部奖牌行复用(与联队份额同一次读取的理由)。
members, err := loadEntryMembers(db)
if err != nil {
return MedalDetailWorkbook{}, err
}
ctx, err := loadMedalContext(db, members)
if err != nil { if err != nil {
return MedalDetailWorkbook{}, err return MedalDetailWorkbook{}, err
} }
@@ -340,10 +345,12 @@ type medalContext struct {
categoryID int64 categoryID int64
} }
func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) { // loadMedalContext 取每条成绩的展示字段;members 是报名 id → 队员(见 loadEntryMembers),
// 用来把团体报名的队员写进「运动员 / 队」列。
func loadMedalContext(db *sql.DB, members map[int64][]entryMember) (map[int64]medalContext, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''), SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
COALESCE(a.name, ''), COALESCE(en.member_names, ''), COALESCE(a.name, ''), COALESCE(en.member_names, ''), en.id,
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0), COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0),
e.id, COALESCE(c.id, 0) e.id, COALESCE(c.id, 0)
FROM results r FROM results r
@@ -363,14 +370,15 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
resultID int64 resultID int64
athleteName string athleteName string
memberJSON string memberJSON string
entryID int64
c medalContext c medalContext
) )
if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category, if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category,
&athleteName, &memberJSON, &c.sportSort, &c.eventSort, &c.catSort, &athleteName, &memberJSON, &entryID, &c.sportSort, &c.eventSort, &c.catSort,
&c.eventID, &c.categoryID); err != nil { &c.eventID, &c.categoryID); err != nil {
return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err) return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err)
} }
c.participant = participantName(athleteName, memberJSON) c.participant = participantLabel(athleteName, memberJSON, members[entryID])
out[resultID] = c out[resultID] = c
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
+20 -2
View File
@@ -123,8 +123,13 @@ type entryDTO struct {
Squad string `json:"squad"` Squad string `json:"squad"`
AthleteID *int64 `json:"athleteId"` AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"` MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"` // Athletes 是这条报名的**队员**(团队 / 对抗项目的成员运动员),读侧始终下发数组
Seed *string `json:"seed"` // (空数组 = 只报单位,与升级前一致;不是 null,前端不必写两套渲染)。
// 它是人员关联(athleteId),memberNames 只是显示用的文字名单——两者可以同时存在:
// 老数据、以及「人没录档案只登记姓名」的报名就只有 memberNames。
Athletes []entryAthleteDTO `json:"athletes"`
Bib *string `json:"bib"`
Seed *string `json:"seed"`
// Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。 // Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。
Heat *int64 `json:"heat"` Heat *int64 `json:"heat"`
Status string `json:"status"` Status string `json:"status"`
@@ -139,6 +144,19 @@ type entryDTO struct {
TeamShares []entryTeamShareDTO `json:"teamShares"` TeamShares []entryTeamShareDTO `json:"teamShares"`
} }
// entryAthleteDTO 是报名上的一名**队员**(devdocs/20):团队 / 对抗项目的成员运动员。
//
// - athleteId:人员档案的 id,**唯一身份**(认人时以号码布为准,姓名会重名);
// - name / bib:展示字段(前端显示「姓名(号码布)」),写侧不需要带。
//
// 写侧(PUT /api/entries/{id}/athletes)只认 athleteId:把姓名 / 号码布认成人是导入与界面
// 的事(导入的「队员名单」列可以写号码布),服务端不在这里二次猜测。
type entryAthleteDTO struct {
AthleteID int64 `json:"athleteId"`
Name string `json:"name,omitempty"`
Bib *string `json:"bib,omitempty"`
}
// entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。 // entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。
// //
// - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价; // - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价;
+280
View File
@@ -0,0 +1,280 @@
package httpapi
import (
"context"
"database/sql"
"fmt"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是「团体报名的队员」(devdocs/20)的读写。
//
// 表达方式:**队员是报名上的人员关联**(entry_athletes),不是新的一条报名,也不改任何唯一键。
// 与联队(entry_team_shares)对称而语义相反:那边是「一条报名 → 多个单位、按权重**拆**分」,
// 这边是「一条报名 → 多个运动员、每人各拿**整份**分」(个人奖牌也是每人各一枚)。
//
// 写侧只认**运动员 id**:把姓名 / 号码布认成人是导入与界面的事(导入口径见 devdocs/20 §三:
// 队员名单列可以填号码布),这里只负责把已经认准的身份留在报名上,不做二次猜测。
// maxEntryAthletes 是单条报名的队员上限。现场最大的团体项目(篮球、拔河)也就十几人,
// 给到 30 已经远超需要;上限的作用是拦住「一整格都是 id」这类误填——每个队员都会产出
// 一条名次与一条个人流水,放进来多少就落多少。
const maxEntryAthletes = 30
// entryAthletesReady 判断队员表在不在。
//
// 与 entryTeamSharesReady 同一套:读路径要能读「还没跑到 0026 的库」(迁移在服务启动时补跑,
// 但榜单可能在那之前被读一次)。表不在 = 库里没有任何队员关联 = 只报单位,正是老数据本来
// 就该得到的结果,所以按「没有队员」处理是**精确**的,不是容错降级。
func entryAthletesReady(ctx context.Context, q queryer) (bool, error) {
var n int
if err := q.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
return false, err
}
return n != 0, nil
}
// loadEntryAthletesQ 读一条 SQL 里给定范围内报名的队员,按 entry_id 归组,供引擎使用。
// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。
//
// 排序按 (sort_order, athlete_id):写侧按请求给的顺序编号,展示顺序稳定;
// 引擎不依赖顺序(每个队员各拿一整份分,与先后无关)。
func loadEntryAthletesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.EntryAthlete, error) {
ready, err := entryAthletesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
query := `SELECT ea.entry_id, ea.athlete_id, COALESCE(a.name, '')
FROM entry_athletes ea
JOIN athletes a ON a.id = ea.athlete_id`
args := []any{}
if eventID != 0 {
query += ` JOIN entries e ON e.id = ea.entry_id WHERE e.event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.EntryAthlete)
for rows.Next() {
var (
entryID int64
m domain.EntryAthlete
)
if err := rows.Scan(&entryID, &m.AthleteID, &m.Name); err != nil {
return nil, internal(err)
}
out[entryID] = append(out[entryID], m)
}
return out, rows.Err()
}
// loadEntryAthletesDTOs 读某小项全部报名的队员读数形态:带上号码布(运动员的唯一标识),
// 前端据此显示「姓名(号码布)」并让人对得上下一次导入写的是谁。
func (s *server) loadEntryAthletesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryAthleteDTO, error) {
ready, err := entryAthletesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
rows, err := q.QueryContext(ctx,
`SELECT ea.entry_id, ea.athlete_id, a.name, a.bib
FROM entry_athletes ea
JOIN entries e ON e.id = ea.entry_id
JOIN athletes a ON a.id = ea.athlete_id
WHERE e.event_id = ?
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]entryAthleteDTO)
for rows.Next() {
var (
entryID int64
item entryAthleteDTO
bib sql.NullString
)
if err := rows.Scan(&entryID, &item.AthleteID, &item.Name, &bib); err != nil {
return nil, internal(err)
}
item.Bib = strPtr(bib)
out[entryID] = append(out[entryID], item)
}
return out, rows.Err()
}
// entryAthletesOf 把一条报名的队员补齐成「始终有值」的数组:没有队员时是空数组而不是 null,
// 前端不必为「有没有队员」写两套渲染(与 memberNames 那种可空文本不同,队员是一组关联)。
func entryAthletesOf(configured []entryAthleteDTO) []entryAthleteDTO {
if len(configured) == 0 {
return []entryAthleteDTO{}
}
return configured
}
// replaceEntryAthletes 按给定顺序重写一条报名的队员(先删后插,幂等)。
//
// 为什么每次都重写、而不是「有队员才写」:报名可以被改成另一个样子(换人、减人、清空)。
// 只增不减会留下旧队员,那条报名以后一直按早就改掉的人头发个人分。
// 传空数组是**合法值**:清空队员 = 只报单位(与升级前一致)。
func replaceEntryAthletes(ctx context.Context, q queryer, entryID int64, athletes []entryAthleteDTO) error {
if _, err := q.ExecContext(ctx, `DELETE FROM entry_athletes WHERE entry_id = ?`, entryID); err != nil {
return internal(err)
}
for i, a := range athletes {
if _, err := q.ExecContext(ctx,
`INSERT INTO entry_athletes (entry_id, athlete_id, sort_order) VALUES (?, ?, ?)`,
entryID, a.AthleteID, i); err != nil {
return internal(err)
}
}
return nil
}
// validateEntryAthletes 校验一次队员改动,返回给人看的 400 错误。
//
// 规则(每一条都对着一种会算错的写法):
// - athleteId 必填且必须存在:队员身份只认运动员档案,姓名与号码布只是显示;
// - 同一个人只能出现一次:重复会让同一条成绩给同一个人记两份分、两枚牌;
// - 人数不超过 maxEntryAthletes:拦住「一整格都是 id」这类误填。
func (s *server) validateEntryAthletes(ctx context.Context, athletes []entryAthleteDTO) error {
if len(athletes) > maxEntryAthletes {
return badRequest(fmt.Sprintf("一条报名的队员最多 %d 人:队伍人数不可能这么多,请检查是不是误填", maxEntryAthletes), nil)
}
ids := make([]int64, 0, len(athletes))
seen := make(map[int64]bool, len(athletes))
for i, a := range athletes {
if a.AthleteID == 0 {
return badRequest("athletes[].athleteId 必填:队员身份只认运动员档案里的那个人(不是姓名文字)", i)
}
if seen[a.AthleteID] {
return badRequest(fmt.Sprintf("athletes 里运动员 %d 出现了两次:同一条报名里一个人只能占一个位置", a.AthleteID), i)
}
seen[a.AthleteID] = true
ids = append(ids, a.AthleteID)
}
if len(ids) == 0 {
return nil
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return internal(err)
}
defer func() { _ = rows.Close() }()
exists := make(map[int64]bool, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return internal(err)
}
exists[id] = true
}
if err := rows.Err(); err != nil {
return err
}
missing := make([]int64, 0)
for _, id := range ids {
if !exists[id] {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return badRequest("athleteId 不存在(人员已被彻底删除时也会查不到)", missing)
}
return nil
}
// updateEntryAthletes 整组替换一条报名的队员并重算它所在的小项组别。
//
// 必须重算(与改份额那条接口同口径,而与改队名那条不同):队员决定「哪些人拿到个人分与
// 个人奖牌」,改完不重算就是「界面显示换人了、榜单还是旧人」。队名 / 批次那类纯展示字段
// 才走「不重算」那条路。
func (s *server) updateEntryAthletes(ctx context.Context, entryID int64, athletes []entryAthleteDTO, actorID int64) (entryDTO, error) {
entry, err := s.getEntry(ctx, entryID)
if err != nil {
return entryDTO{}, err
}
// 带运动员的报名自己就是那个人的身份((组别, 单位, 运动员) 唯一),再挂队员等于说
// 「这条报名既是一个人又是一支队」,两条口径会同时往个人榜上写。直接拒掉并说清原因。
if entry.AthleteID != nil {
return entryDTO{}, badRequest("这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名", nil)
}
if err := s.validateEntryAthletes(ctx, athletes); err != nil {
return entryDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return entryDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
if err := replaceEntryAthletes(ctx, tx, entryID, athletes); err != nil {
return entryDTO{}, err
}
score, err := recomputeLedgerTx(ctx, tx, entry.EventID, entry.CategoryID, actorID, "报名队员变更后重算")
if err != nil {
return entryDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return entryDTO{}, err
}
if err := s.bumpEvent(ctx, tx, entry.EventID); err != nil {
return entryDTO{}, err
}
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return entryDTO{}, err
}
if err := tx.Commit(); err != nil {
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, entryID)
}
// updateEntryAthletesHandler 是 PUT /api/entries/{id}/athletes:
// 整组替换一条团体报名的队员(空数组 = 清空,只报单位),成功后返回这条报名的完整读数。
func (s *server) updateEntryAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in entryAthletesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getEntry(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEntryAthletes(r.Context(), id, in.Athletes, middleware.User(r.Context()).ID)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.athletes.update", "entry", id,
map[string]any{"athletes": before.Athletes}, map[string]any{"athletes": updated.Athletes})
return updated, nil
})
}
@@ -0,0 +1,249 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路:
// - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致);
// - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混);
// - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次,
// 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。
// seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道
// (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。
// 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。
func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order,
count_to_team, count_to_team_medals)
VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`,
eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals)
return eventID, categoryID
}
// doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。
func doublesImportRow(eventCode, category, teamCode string) string {
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",")
}
// putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。
func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) {
t.Helper()
items := make([]map[string]any, 0, len(athleteIDs))
for _, id := range athleteIDs {
items = append(items, map[string]any{"athleteId": id})
}
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": items}, true)
}
// entryAthleteRows 把某小项的队员关联表导成稳定文本。
func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
JOIN entries e ON e.id = ea.entry_id
WHERE e.event_id = %d
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID))
}
// athleteBoard 是 GET /api/standings/athletes 的原始读数。
func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any {
t.Helper()
status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error)
}
return decodeData[[]map[string]any](t, resp)
}
// athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。
func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any {
t.Helper()
for _, row := range athleteBoard(t, env, client) {
if row["athleteId"] == float64(athleteID) {
return row
}
}
t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client))
return nil
}
// assertAthlete 断言个人榜上某人的总分与金银铜。
func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) {
t.Helper()
row := athleteRow(t, env, client, athleteID)
if got := row["totalPoints"].(float64); got != points {
t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, got, points, row)
}
for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} {
if got := row[name].(float64); got != float64(want) {
t.Errorf("运动员 %d 的 %s = %v,期望 %d(整行:%+v)", athleteID, name, got, want, row)
}
}
}
// 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌;
// 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。
func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
// 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
// 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want)
}
publishResults(t, env, client, eventID, "doubles-members",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
// 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。
func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "doubles-flags-off",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。
assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0)
// 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
}
// 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。
func TestEntryReadsIncludeAthletes(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
}
board := decodeData[entryBoard](t, resp)
if len(board.Entries) != 1 {
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
}
athletes, _ := board.Entries[0]["athletes"].([]any)
if len(athletes) != 2 {
t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"])
}
first, _ := athletes[0].(map[string]any)
if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" {
t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first)
}
}
// 队员改动要能拦住会算错的写法,并且一律不落库。
func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK {
t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error)
}
baseline := entryAthleteRows(t, env, eventID)
// 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。
personalEntryID := ids.entryA
cases := []struct {
name string
path string
body map[string]any
want string
}{
{"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"},
{"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"},
{"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"},
{"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID),
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"},
{"报名不存在", "/api/entries/999999/athletes",
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true)
wantStatus := http.StatusBadRequest
if tc.name == "报名不存在" {
wantStatus = http.StatusNotFound
}
if status != wantStatus {
t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != baseline {
t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got)
}
})
}
// 空数组是合法值:清空队员 = 只报单位(与升级前一致)。
if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("清空后队员表应为空,得到 %s", got)
}
}
@@ -0,0 +1,202 @@
package httpapi_test
import (
"database/sql"
"fmt"
"testing"
)
// 本文件是「老库升级到 0026」这一跳在接口层的验证:拿一个升级前(0025)的库,塞进老数据,
// 走真实升级路径(store.MigrateEvent)后从报名一路打到榜单。
//
// 三条底线:
// - 回填:老报名 member_names 里认得出的人被挂上(号码布优先、姓名+单位其次、重名不猜);
// - 队员拿个人分与个人奖牌(这是本单要的效果);
// - **单位维度不变**:单位总分与单位奖牌仍旧只算这条报名一次。
// seedLegacyDoublesEvent 在升级前的库里造两条「双打」团体报名(A、B 两个单位各一条):
// 团体项目 + 个人通道(现场的双打就是这么配的),队员名单只有文字(老数据的唯一形态)。
// 两个 memberNames 参数分别是 B 单位那条与 A 单位那条的队员名单(NULL 表示没填名单)。
func seedLegacyDoublesEvent(t *testing.T, db *sql.DB, memberNamesB, memberNamesA string) {
t.Helper()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("造老库数据失败: %v\n%s", err, query)
}
}
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
// 号码布是运动员的唯一标识:下面按号码布/姓名两种写法各认一个。
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('张伟', '男', 1, 'A001', 1)`)
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('王五', '男', 1, 'A002', 1)`)
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('李强', '男', 2, 'B001', 1)`)
exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status)
VALUES (1, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 1, 'ongoing')`)
exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`)
exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`)
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
VALUES (1, 1, 2, NULL, ?, 'registered')`, memberNamesB)
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status, squad)
VALUES (1, 1, 1, NULL, ?, 'registered', '')`, memberNamesA)
}
// newLegacyDoublesEnv 造一个「升级前的老库 → 升级 → 起服务」的完整现场。
func newLegacyDoublesEnv(t *testing.T, memberNamesB, memberNamesA string) *testEnv {
t.Helper()
return newTestEnvPrepared(t, func(ev *sql.DB) {
applyEventMigrationsBefore(t, ev, "0026") // 回到「升级前」:库结构到 0025 为止
seedLegacyDoublesEvent(t, ev, memberNamesB, memberNamesA)
}, nil)
}
// 老库升级:名单里认得出的人被回填成队员,于是双打两个人各拿一份个人分、各一枚个人奖牌;
// 而单位维度与升级前逐字节一致(单位只算一次分、只拿一枚牌)。
func TestLegacyDoublesUpgradeBackfillsMembersAndScoresIndividually(t *testing.T) {
// 老报名写的是一半号码布(A001 = 张伟)、一半姓名(王五)——两种写法都该认出来。
// 另一条报名(B 单位)写的是姓名「李强」,也在升级时认出来(下面 dump 里是第 1 行)。
env := newLegacyDoublesEnv(t, `["李强"]`, `["A001","王五"]`)
// 升级那一刻做的回填:两个人都挂上了,顺序按 member_names 的书写顺序。
dump := tableDump(t, env, `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
ORDER BY ea.entry_id, ea.sort_order`)
want := "entry_id=1 athlete_id=3 name=李强 sort_order=0 \n" +
"entry_id=2 athlete_id=1 name=张伟 sort_order=0 \n" +
"entry_id=2 athlete_id=2 name=王五 sort_order=1 \n"
if dump != want {
t.Fatalf("升级回填不对:\n得到 %s期望 %s", dump, want)
}
client, _ := env.client()
env.createUser("recorder", "recorder-pass", "recorder")
env.login(client, "recorder", "recorder-pass")
entryA := entryIDOf(t, env, 1, 1) // A 单位的双打
entryB := entryIDOf(t, env, 2, 1) // B 单位的单人报名
publishResults(t, env, client, 1, "legacy-doubles-upgrade",
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
)
// 个人榜:双打的两个人各一份满分(第 1 名 10 分)+ 各一枚金牌。
assertAthlete(t, env, client, 1, 10, 1, 0, 0) // 张伟
assertAthlete(t, env, client, 2, 10, 1, 0, 0) // 王五
assertAthlete(t, env, client, 3, 8, 0, 1, 0) // 李强(对照)
// 单位维度:分配了队员也仍然只算这条报名一次(不是 20 分、也不是两枚金牌)。
assertStanding(t, env, client, 1, 10, 1, 0, 0)
assertStanding(t, env, client, 2, 8, 0, 1, 0)
// 流水层面把「不重复计算」摊开看:单位那一份只有一条(athlete_id 为空、count_to_team=1),
// 队员的两条是「只记个人」(count_to_team=0),所以单位总分与单位奖牌都看不到它们。
ledger := tableDump(t, env, `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 ORDER BY team_id, athlete_id`)
wantLedger := "team_id=1 athlete_id=0 points=10 count_to_team=1 \n" +
"team_id=1 athlete_id=1 points=10 count_to_team=0 \n" +
"team_id=1 athlete_id=2 points=10 count_to_team=0 \n" +
"team_id=2 athlete_id=0 points=8 count_to_team=1 \n" +
"team_id=2 athlete_id=3 points=8 count_to_team=0 \n"
if ledger != wantLedger {
t.Fatalf("流水不对:\n得到 %s期望 %s", ledger, wantLedger)
}
}
// 名单认不出人的老报名(重名、查无此人)升级后照旧:上不了个人榜,单位维度一个字不变。
func TestLegacyDoublesUpgradeWithoutMatchKeepsBehavior(t *testing.T) {
// 两条报名写的名字都查不到人(重名 / 查无此人):一条关联都不许回填。
env := newLegacyDoublesEnv(t, `NULL`, `["查无此人","李强","李强"]`)
if got := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes`); got != 0 {
t.Fatalf("认不出人时不该回填任何关联(B 单位没填名单、A 单位查无此人),得到 %d 条", got)
}
client, _ := env.client()
env.createUser("recorder", "recorder-pass", "recorder")
env.login(client, "recorder", "recorder-pass")
entryA := entryIDOf(t, env, 1, 1)
entryB := entryIDOf(t, env, 2, 1)
publishResults(t, env, client, 1, "legacy-doubles-nomatch",
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
)
// 单位维度与升级前一样(A 第 1 名 10 分 1 金,B 第 2 名 8 分 1 银)。
assertStanding(t, env, client, 1, 10, 1, 0, 0)
assertStanding(t, env, client, 2, 8, 0, 1, 0)
// 个人榜上一条都没有:队员认不出来就上不了个人榜(这正是回填要解决的事)。
if rows := athleteBoard(t, env, client); len(rows) != 0 {
t.Fatalf("认不出人时个人榜应为空,得到 %+v", rows)
}
}
// 双打 + 双通道(个人成绩通道 + 团体手工通道):连发两次必须逐字节一致。
//
// results.rank 在团体报名项上是「单位名次」的录入位(团体手工通道的兜底来源)。
// 队员的个人副本不许往这一列写回自动名次——否则第二次发布会把自动名次当成团体名次再发一遍分,
// 就是工单 A-1 那个坑,只不过这次是队员副本踩进去的。
func TestDoublesMembersDoNotClobberManualTeamRank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DBL2', '羽毛球双打(双通道)', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport), int64(0)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6"))
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6"))
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != 200 {
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
}
// 团体名次按单位录(第 3 名 = 8 分),双打自己的成绩值给出个人通道的第 1 名(10 分)。
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 3},
}); status != 200 {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "doubles-dual-p1",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
firstLedger := tableDump(t, env, fmt.Sprintf(
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID))
// 名次不许被队员副本写回:团体报名项的 results.rank 仍是「没写」(名次按单位录入在
// channel_manual_ranks 里,这一列是它的兜底录入位,被自动名次覆盖就会在第二次发布时
// 被当成团体名次再发一遍分)。
if got := resultRanksDump(t, env, eventID); got != fmt.Sprintf("entry_id=%d rank=0 value=90 \n", entryID) {
t.Fatalf("团体名次(results.rank 录入位)被自动名次改写了:\n%s", got)
}
// 单位总分 = 个人通道第 1 名的 10 分(报名项那一份)+ 团体手工通道第 3 名的 8 分,只算一次。
// 奖牌 1 金 1 铜是双通道本来就有的口径(两条通道各自发自己那份名次的牌,与队员无关)。
assertStanding(t, env, client, ids.teamA, 18, 1, 0, 1)
// 队员各拿个人通道那一份 10 分与个人金牌。
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
publishEvent(t, env, client, eventID, "doubles-dual-p2")
if got := tableDump(t, env, fmt.Sprintf(
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID)); got != firstLedger {
t.Fatalf("第二次发布改了流水(同一份数据发两次结果不同):\n第一次\n%s第二次\n%s", firstLedger, got)
}
}
// entryIDOf 取某 (单位, 小项) 的纯团体报名 id。
func entryIDOf(t *testing.T, env *testEnv, teamID, eventID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(
`SELECT id FROM entries WHERE team_id = ? AND event_id = ? AND athlete_id IS NULL`, teamID, eventID).Scan(&id); err != nil {
t.Fatalf("读报名项: %v", err)
}
return id
}
+5
View File
@@ -393,6 +393,11 @@ func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error)
return entryDTO{}, err return entryDTO{}, err
} }
e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName) e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName)
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, e.EventID)
if err != nil {
return entryDTO{}, err
}
e.Athletes = entryAthletesOf(athletes[e.ID])
return e, nil return e, nil
} }
+176 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/csv" "encoding/csv"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -13,6 +14,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -1189,6 +1191,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算, // 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。 // 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
affectedScopes := make([]entryScope, 0, 2) affectedScopes := make([]entryScope, 0, 2)
// 「队员名单」列按号码布认出来、并挂到报名上的队员总数。收尾说一句:这些人会参与个人算分,
// 认不出的照旧只是文字名单——不报错,但现场得知道「这份表到底认上了几个人」。
linkedMembers := 0
for _, table := range tables { for _, table := range tables {
index, err := importColumnIndex(table.rows[0]) index, err := importColumnIndex(table.rows[0])
if err != nil { if err != nil {
@@ -1447,6 +1452,68 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
squad = "" squad = ""
} }
// 「队员名单」列既是显示用的文字,也能写**号码布**:一格里的每一段先按号码布去
// athletes 认人,认得上就把这条报名跟那个人关联起来(entry_athletes),
// 认不上的照旧只当文字(现状不变,不报错)。号码布是运动员的唯一标识(devdocs/20 §三)。
memberTokens := splitNames(cell("member_names"))
link, err := s.importMemberAthletes(ctx, memberTokens)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
for _, name := range link.Repeated {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 写了两次,只关联一个人(同一条报名里一个人只占一个位置)", line, name))
}
// 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的
// 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。
// 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。
if athleteID != nil && len(link.Members) > 0 {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:"+
"队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人",
line, len(link.Members)))
link.Members = nil
}
// 一条报名的队员上限与 PUT /api/entries/{id}/athletes 共用:超过就整行拦下,不写一条
// 界面上改不动的报名(与联队「一个联队最多 5 个单位」同一条口径)。认不出人的段不计入,
// 所以只写姓名的老表一行都不会被它拦下。
if len(link.Members) > maxEntryAthletes {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"队员名单里认出了 %d 名运动员,超过一条报名最多 %d 人:请检查这一格是不是把号码布写错了位置",
len(link.Members), maxEntryAthletes))
continue
}
// 号码布认到别的单位的人:照号码布认(与迁移回填同一口径:号码布是唯一标识,不看单位),
// 但要点名说清 —— 团体报名挂在 A 单位、队员却是 B 单位的人,最常见的原因是号码布写错了。
// 联队(team_code 写 A+B)的成员单位都算「本行的单位」:队员本来就该来自其中任何一个,
// 不该报一个会让人去改正确号码布的假警报。
// 这里**不**进冲突面板:那是「要不要用表里的字段覆盖库里这条人员档案」的决策,
// 而队员关联一个字段都不改(与 PUT /api/entries/{id}/athletes 一样只认 athleteId)。
for _, member := range link.Members {
if member.TeamID == teamID || sharesContainTeam(teamShares, member.TeamID) {
continue
}
memberTeam := member.TeamCode
if memberTeam == "" {
memberTeam = "(无单位)"
}
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 是单位 %s 的 %s,本行报名挂在单位 %s 名下 —— "+
"号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错",
line, member.Bib, memberTeam, member.Name, teamCode))
}
members := make([]entryAthleteDTO, 0, len(link.Members))
for _, member := range link.Members {
members = append(members, entryAthleteDTO{AthleteID: member.AthleteID})
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{ outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID, EventID: eventID,
CategoryID: categoryID, CategoryID: categoryID,
@@ -1454,7 +1521,8 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
TeamShares: teamShares, TeamShares: teamShares,
Squad: squad, Squad: squad,
AthleteID: athleteID, AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")), MemberNames: memberTokens,
Athletes: members,
Bib: nilIfEmpty(bib), Bib: nilIfEmpty(bib),
Seed: nilIfEmpty(cell("seed")), Seed: nilIfEmpty(cell("seed")),
Heat: heat, Heat: heat,
@@ -1466,6 +1534,7 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error())) result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
case outcome.Created: case outcome.Created:
result.Created++ result.Created++
linkedMembers += len(members)
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID) affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员 // 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。 // 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
@@ -1476,7 +1545,10 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
} }
default: default:
result.Updated++ result.Updated++
if outcome.StatusChanged { linkedMembers += len(members)
// 队员关联与 status 跨界同一类:它们决定「谁拿到分」,改了就要重算这一组,
// 否则会出现「名单里认出了队员、榜上还没他的分」(与 PUT /api/entries/{id}/athletes 同口径)。
if outcome.StatusChanged || outcome.MembersChanged {
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID) affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
} }
} }
@@ -1519,6 +1591,14 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
result.Notices = append(result.Notices, fmt.Sprintf( result.Notices = append(result.Notices, fmt.Sprintf(
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows)) "号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
} }
// 队员名单认了多少人,用既有 notices 通道说一句:号码布认得出人时,这些人就从「文字名单」
// 变成了「真实的队员」,会各自拿到个人分与个人奖牌(配了个人通道的项目才上个人榜)。
// 认不出的一律照旧只是文字,不报错——人没录档案是常态,这正是降级形态存在的意义。
if linkedMembers > 0 {
result.Notices = append(result.Notices, fmt.Sprintf(
"队员名单列按号码布关联了 %d 名队员(号码布是运动员的唯一标识):配了个人通道的项目,他们会各自拿到个人分与个人奖牌;"+
"只写姓名、或号码布认不出人的照旧只作文字名单", linkedMembers))
}
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。 // 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。 // 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil { if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
@@ -1574,6 +1654,100 @@ func splitNames(raw string) []string {
return out return out
} }
// importMember 是一格「队员名单」里按号码布认出来的一个人。
//
// 写进报名报的只有 AthleteID(与 PUT /api/entries/{id}/athletes 同一口径:认人是调用方的事);
// 姓名、号码布与单位只用于报告 —— 「认到的是谁、他属于哪个单位」。
type importMember struct {
AthleteID int64
Name string
Bib string
TeamID int64
TeamCode string
}
// importMemberLink 是一格「队员名单」认人的结果。
type importMemberLink struct {
// Members 按这一格里的书写顺序给出认出来的人(同一个号码布写多次只留一次),
// 写进报名的 sort_order 就用这个顺序(与迁移回填「按 member_names 书写顺序」同一口径)。
Members []importMember
// Repeated 是同一格里写了两次以上、且认得出人的号码布,按首次出现顺序。
Repeated []string
}
// importMemberAthletes 把「队员名单」一格里的每一段按**号码布**认成人。
//
// 口径(devdocs/20 §三,主人 2026-09-14 定的):
// - 号码布是运动员的唯一标识(0014 上有部分唯一索引):一段文字与某个运动员的号码布完全相同,
// 那就是他 —— **与单位无关**(迁移里的回填 1 同样不看单位),单位不同只作提示;
// - 一格里的内容同时能当成号码布与姓名时,以号码布为准:先认号码布,命中了就不再看姓名;
// - 认不出的段**保持原样当文字**(member_names 照常保留,现状不变):现场难免有没录档案的人。
// 姓名一律不做匹配——同名会认错人,而认错的分已经上了榜(老数据的姓名回填是迁移里的一次性动作,
// 且要求同单位唯一,导入不重复这条不确定性)。
//
// 一格一次查询:格子里的段数最多十几段,但一份表可能有几百行,逐段查会把导入变成几百次往返。
func (s *server) importMemberAthletes(ctx context.Context, tokens []string) (importMemberLink, error) {
if len(tokens) == 0 {
return importMemberLink{}, nil
}
seen := make(map[string]bool, len(tokens))
args := make([]any, 0, len(tokens))
for _, token := range tokens {
if seen[token] {
continue
}
seen[token] = true
args = append(args, token)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT a.bib, a.id, a.name, a.team_id, COALESCE(t.code, '')
FROM athletes a LEFT JOIN teams t ON t.id = a.team_id
WHERE a.bib IN (`+placeholders(len(args))+`)`, args...)
if err != nil {
return importMemberLink{}, internal(err)
}
defer func() { _ = rows.Close() }()
byBib := make(map[string]importMember, len(args))
for rows.Next() {
var (
m importMember
bib sql.NullString
code string
)
if err := rows.Scan(&bib, &m.AthleteID, &m.Name, &m.TeamID, &code); err != nil {
return importMemberLink{}, internal(err)
}
m.Bib = bib.String
m.TeamCode = code
byBib[m.Bib] = m
}
if err := rows.Err(); err != nil {
return importMemberLink{}, internal(err)
}
var link importMemberLink
emitted := make(map[int64]bool)
for _, token := range tokens {
member, ok := byBib[token]
if !ok {
// 认不出人:照旧只当文字(member_names 已经把它留在库里了)。
continue
}
if emitted[member.AthleteID] {
// 同一个号码布写了两次:同一条报名里一个人只占一个位置(表的主键也这么定),
// 这里就地去掉并记下来,由调用方说一句。
if !slices.Contains(link.Repeated, member.Bib) {
link.Repeated = append(link.Repeated, member.Bib)
}
continue
}
emitted[member.AthleteID] = true
link.Members = append(link.Members, member)
}
return link, nil
}
// ---------- 图片上传与静态访问 ---------- // ---------- 图片上传与静态访问 ----------
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节, // maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
@@ -0,0 +1,394 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「导入侧把队员名单认成人」(devdocs/20 §三):
// - 「队员名单」列除了是显示用的文字,还能写**号码布**:认得上就建立 entry_athletes 关联,
// 认不上照旧只当文字(现状不变,不报错);
// - 号码布是运动员的唯一标识:一格里的内容同时能当成号码布与姓名时,以号码布为准;
// - 新增与更新两条路都要同步队员关联:认出了人的那一格整组重写(与 squad / 份额同一口径),
// 一个号码布都认不出的格子只当文字、不动已有队员(老表与界面两条路的并存,见各用例)。
// memberImportRow 拼一行团体报名(不填姓名 = 团体行),列序与 importHeader 一致。
func memberImportRow(eventCode, category, teamCode, members string) string {
return strings.Join([]string{eventCode, category, teamCode, "",
"", "", "", "", "", "registered", members}, ",")
}
// seedBib 给库里已有的运动员写一个号码布(种子里的运动员默认没有号码布)。
func seedBib(t *testing.T, env *testEnv, athleteID int64, bib string) {
t.Helper()
env.exec(t, `UPDATE athletes SET bib = ? WHERE id = ?`, bib, athleteID)
}
// seedMember 造一个带号码布的运动员(导入要认的就是号码布)。
func seedMember(t *testing.T, env *testEnv, name string, teamID int64, bib string) int64 {
t.Helper()
return env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
VALUES (?, '男', ?, ?, 1)`, name, teamID, bib)
}
// entryMemberNames 读一条报名的文字名单原文(member_names 在库里是 JSON 数组文本)。
func entryMemberNames(t *testing.T, env *testEnv, entryID int64) string {
t.Helper()
var raw string
if err := env.ev.QueryRow(`SELECT COALESCE(member_names, '') FROM entries WHERE id = ?`, entryID).Scan(&raw); err != nil {
t.Fatalf("读 member_names: %v", err)
}
return raw
}
// noticesText 把 notices 拼成一段文本,便于断言「说了什么」。
func noticesText(result entryImportResultBody) string {
return strings.Join(result.Notices, "\n")
}
// 号码布认得人:一格里的号码布与该行的文字名单并存 —— 关联建立、文字原样保留,
// 且这些队员当场就能上个人积分榜(导入路径的完整目的)。
func TestImportMemberNamesLinksByBibAndKeepsText(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A002/A001/李四"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("应新建 1 条报名且无报错,得到 %+v", result)
}
entryID := firstEntryID(t, env, eventID)
// 顺序跟这一格的书写顺序走(号码布写在前的排在前)。
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \nentry_id=%d athlete_id=%d name=张伟 sort_order=1 \n",
entryID, partner, entryID, ids.athleteA)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("队员关联应按号码布认出王五与张伟:\n得到 %s期望 %s", got, want)
}
// 认不出的「李四」不报错,文字名单三个都原样留着。
if got, want := entryMemberNames(t, env, entryID), `["A002","A001","李四"]`; got != want {
t.Fatalf("member_names 应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "关联了 2 名队员") {
t.Fatalf("认了人要说得出来(现场要知道谁上了个人榜),得到 notices:%s", got)
}
// 认出来的人当场参与算分:每人一份满分、各一枚个人奖牌;单位仍然只算这一条报名一次。
publishResults(t, env, client, eventID, "import-members",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 一格里的内容同时能当成号码布与姓名时,以号码布为准(主人 2026-09-14 定的口径)。
func TestImportMemberNamesPrefersBibOverName(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
byBib := seedMember(t, env, "赵六", ids.teamA, "1001")
byName := seedMember(t, env, "1001", ids.teamA, "")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "1001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=赵六 sort_order=0 \n", entryID, byBib)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("1001 既是号码布又是另一个人的姓名时应关联号码布那个人:\n得到 %s期望 %s", got, want)
}
var linked int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entry_athletes WHERE athlete_id = ?`, byName).Scan(&linked); err != nil {
t.Fatalf("查关联: %v", err)
}
if linked != 0 {
t.Fatalf("不该关联同名的另一个人(%d)", byName)
}
}
// 只写姓名(认不出号码布)时现状不变:只当文字,不报错、不关联。
func TestImportMemberNamesUnknownStaysTextOnly(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
// 库里有个同单位同名的张伟,但号码布列认的是号码布:姓名不做匹配(同名会认错人)。
seedBib(t, env, ids.athleteA, "A001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "张伟/李四"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("只写姓名应照常导入、不报错,得到 %+v", result)
}
entryID := firstEntryID(t, env, eventID)
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("只写姓名不该建立任何关联,得到 %s", got)
}
if got, want := entryMemberNames(t, env, entryID), `["张伟","李四"]`; got != want {
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); strings.Contains(got, "关联") {
t.Fatalf("没认到人时不该说关联,得到 notices:%s", got)
}
}
// 号码布认到的人属于别的单位:照号码布认(号码布是唯一标识,与迁移回填同一口径),
// 但要说清楚是谁、在哪两个单位之间 —— 这是「填错号码布」最容易发生的写法。
func TestImportMemberNamesReportsCrossTeamMember(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athB, "B001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "B001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=李强 sort_order=0 \n", entryID, ids.athB)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("号码布认到的人照关联(不看单位):\n得到 %s期望 %s", got, want)
}
notice := noticesText(result)
for _, part := range []string{"第 2 行", "B001", "李强", "单位 B", "单位 A"} {
if !strings.Contains(notice, part) {
t.Fatalf("跨单位关联要说清 %q,得到 notices:%s", part, notice)
}
}
}
// 后补队员:先导入一条只报单位的团体报名、成绩已经发布上榜;之后同一行补上号码布重传,
// 队员要**当场参与算分**(个人榜上出现他们),不必再发布一次成绩。
// 这与「改了谁算数就必须重算这一组」是同一条口径(updateEntryAthletes 也是这样)。
func TestImportMemberNamesRecomputesAfterUpdate(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", ""))
if status != http.StatusOK || !resp.OK {
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
publishResults(t, env, client, eventID, "members-later",
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
// 前置条件:还没有队员,个人榜上不该有这两个人。
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("前置条件:首次导入没有队员,得到 %s", got)
}
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001/A002"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("补队员重传应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
}
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
}
// 重传同一行(走更新那条路)也要整组重写队员:换人跟得上,不留上一次的旧队员;
// 而认不出号码布的格子(整列留空 / 只写姓名)只当文字,不动已有的队员关联。
func TestImportMemberNamesRewrittenOnUpdate(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A002"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
}
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \n", entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("换人后队员应整组重写:\n得到 %s期望 %s", got, want)
}
// 走界面把队员改成两个人(导入与界面是两条并用的路)。
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
t.Fatalf("界面设置队员应 200,得到 %d %+v", status, resp.Error)
}
both := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
// 这一格一个号码布都认不出来(整列留空 / 只写姓名)时**只当文字**:已有的队员关联一行都不动。
// 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移回填出来的队员、界面上勾好的人,
// 都不该被一份没有号码布的名单悄悄清掉(那等于让人无声无息地掉出个人积分榜)。
for _, cell := range []string{"", "张伟/王五"} {
status, resp = importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", cell))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传(队员列 %q)应 200,得到 %d %+v", cell, status, resp.Error)
}
if got := entryAthleteRows(t, env, eventID); got != both {
t.Fatalf("队员列 %q 认不出号码布时不该改动已有队员:\n得到 %s期望 %s", cell, got, both)
}
}
}
// 联队(单位编码写 A+B)的队员本来就该来自任何一个成员单位:认人都认,
// 但这类「跨单位」不是填错号码布,不该报假警报去让人改一个正确的号码布。
func TestImportMemberNamesAcceptsJointTeamMembers(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
seedBib(t, env, ids.athB, "B001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A+B", "A001/B001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=李强 sort_order=1 \n",
entryID, ids.athleteA, entryID, ids.athB)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("联队两个成员单位的队员都该关联:\n得到 %s期望 %s", got, want)
}
if got := noticesText(result); strings.Contains(got, "请核对号码布") {
t.Fatalf("联队成员单位的人不该被当成填错号码布,得到 notices:%s", got)
}
}
// 同一格里把同一个号码布写了两次:只关联一次(同一条报名里一个人只能占一个位置),并说一声。
func TestImportMemberNamesDedupesRepeatedBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", "A001/A001"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \n", entryID, ids.athleteA)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("同一个号码布写两次只该关联一次:\n得到 %s期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "A001") || !strings.Contains(got, "两次") {
t.Fatalf("号码布写了两次要说一声,得到 notices:%s", got)
}
}
// 带运动员姓名的那一行按个人报名处理:队员只属于「只报单位」的团体报名,
// 这一格只作文字保留,并且要说出来(与队伍列同一条口径,不静默丢)。
func TestImportMemberNamesOnIndividualRowIsTextOnly(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
row := strings.Join([]string{"DBL", "男子", "A", "张三", "110101199001011234", "男", "26", "A101",
"", "registered", "A001"}, ",")
status, resp := importCSVWithHeader(t, env, client, importHeader, row)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("个人行应照常导入,得到 %+v", result)
}
var entryID int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND athlete_id IS NOT NULL`, eventID).Scan(&entryID); err != nil {
t.Fatalf("读个人报名: %v", err)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("个人报名不该挂队员,得到 %s", got)
}
if got, want := entryMemberNames(t, env, entryID), `["A001"]`; got != want {
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
}
if got := noticesText(result); !strings.Contains(got, "个人报名") {
t.Fatalf("个人行里的队员名单没有关联,要说清楚,得到 notices:%s", got)
}
}
// 分工作表模板(界面上的「补报名 / 团体报名 CSV」走的就是导入)同样认号码布。
func TestImportSheetTemplateLinksMembersByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
seedBib(t, env, ids.athleteA, "A001")
partner := seedMember(t, env, "王五", ids.teamA, "A002")
data := buildTestXLSX(t, []testSheet{{name: "羽毛球双打@男子", rows: [][]string{
strings.Split(sheetImportHeader, ","),
{"", "", "A", "", "", "", "", "", "", "", "registered", "A001/A002", ""},
}}})
status, resp := env.uploadEntries(t, client, "团体报名.xlsx", data)
if status != http.StatusOK || !resp.OK {
t.Fatalf("分工作表模板应导入成功,得到 %d %+v", status, resp.Error)
}
var eventID int64
if err := env.ev.QueryRow(`SELECT id FROM events WHERE code = 'DBL'`).Scan(&eventID); err != nil {
t.Fatalf("读小项: %v", err)
}
entryID := firstEntryID(t, env, eventID)
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
entryID, ids.athleteA, entryID, partner)
if got := entryAthleteRows(t, env, eventID); got != want {
t.Fatalf("分工作表导入也该按号码布关联队员:\n得到 %s期望 %s", got, want)
}
}
// 一格号码布超过一条报名的队员上限(maxEntryAthletes = 30):整行拦下并说清,
// 不静默写成一条界面改不动的报名(与联队「最多 5 个单位」同一条口径)。
func TestImportMemberNamesRejectsTooManyMembers(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
tokens := make([]string, 0, 31)
for i := 1; i <= 31; i++ {
bib := fmt.Sprintf("M%03d", i)
seedMember(t, env, fmt.Sprintf("队员%d", i), ids.teamA, bib)
tokens = append(tokens, bib)
}
status, resp := importCSVWithHeader(t, env, client, importHeader,
memberImportRow("DBL", "男子", "A", strings.Join(tokens, "/")))
if status != http.StatusOK || !resp.OK {
t.Fatalf("行级拦截应回 200 + 行级报错,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Skipped != 1 || result.Created != 0 {
t.Fatalf("超过上限的行应被拦下,得到 %+v", result)
}
if got := strings.Join(result.Errors, "|"); !strings.Contains(got, "30") {
t.Fatalf("报错要说清上限是多少,得到 %s", got)
}
if got := entryAthleteRows(t, env, eventID); got != "" {
t.Fatalf("被拦下的行不该落队员,得到 %s", got)
}
}
+7
View File
@@ -61,6 +61,13 @@ type entrySquadPayload struct {
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
} }
// entryAthletesPayload 是 PUT /api/entries/{id}/athletes 的请求体:整组替换一条报名的队员。
// 空数组是**合法值**(清空队员 = 只报单位,与升级前一致);只认 athleteId。
type entryAthletesPayload struct {
Athletes []entryAthleteDTO `json:"athletes"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体: // entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名), // action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。 // ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
+6
View File
@@ -861,8 +861,14 @@ func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 队员同理:一条报名 0..N 个,单独查一次按 entry_id 挂上(空数组 = 只报单位)。
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
for i := range out { for i := range out {
out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName) out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName)
out[i].Athletes = entryAthletesOf(athletes[out[i].ID])
} }
return out, nil return out, nil
} }
+78 -3
View File
@@ -525,6 +525,10 @@ type entryImportOutcome struct {
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。 // StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。 // 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
StatusChanged bool StatusChanged bool
// MembersChanged 表示这次更新改动了报名的**队员关联**(换人、加人)。
// 队员决定谁拿到个人分与个人奖牌,所以它必须重算——与 status 跨界同一类「谁还算数」的改动,
// 而与姓名 / 号码布 / 队伍 / 批次那类纯展示字段不同(那些改了不重算)。
MembersChanged bool
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。 // MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见 // 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。 // 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
@@ -693,6 +697,9 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil { if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err return entryImportOutcome{}, err
} }
if _, err := s.syncImportedEntryMembers(ctx, newID, in); err != nil {
return entryImportOutcome{}, err
}
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID} outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
if len(moved) > 0 { if len(moved) > 0 {
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分, // 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
@@ -722,14 +729,82 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil { if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err return entryImportOutcome{}, err
} }
membersChanged, err := s.syncImportedEntryMembers(ctx, existing, in)
if err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{ return entryImportOutcome{
EventID: in.EventID, EventID: in.EventID,
CategoryID: in.CategoryID, CategoryID: in.CategoryID,
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status), StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
MembersChanged: membersChanged,
}, nil }, nil
} }
} }
// syncImportedEntryMembers 把导入这一行认出来的队员整组写到报名上(新增与更新两条路都要调),
// 返回这次有没有真的改动队员关联。
//
// **一格认出了人才重写**:认出了号码布的一格就是这条报名的完整队员名单 —— 整组重写(先删后插,
// 与份额同一口径),重传同一行幂等,删掉一个号码布那个人就跟着从队里出去。
// 一个号码布都认不出来(整列留空、只写姓名、或号码布认不出人)时这一格**只当文字**,
// 已有的队员关联一行都不动:
// - 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移按「号码布优先、其次姓名+单位唯一」回填
// 出来的队员,不能因为重传一份没有号码布的名单就被悄悄清掉(那等于让人无声无息掉出个人积分榜);
// - 界面上勾好的队员也不能被「用模板重导一遍」冲掉(模板的队员名单列本来就是空的);
// - 要清空队员走界面(PUT /api/entries/{id}/athletes 传空数组),那是明确表达出来的意图。
// 这条与「认不上就只当文字、别报错」是同一句话:认不出的格子对这条报名不产生任何影响。
//
// 返回值只比**人的集合**,不比顺序:队员顺序只影响「队里有谁」的排列,名次与积分不看它
// (见 entry_athletes 表注释),所以只是换了个写法重传不该把这一组白算一遍。
// 关联真的变了就必须重算——队员决定谁拿到个人分与个人奖牌,不重算就是「名单里换人了、榜上还是旧人」。
//
// 带运动员的报名一个字节都不写:它的身份就是那一个运动员,队员只属于「只报单位」的团体报名
// (与 PUT /api/entries/{id}/athletes 的同一条口径)。
func (s *server) syncImportedEntryMembers(ctx context.Context, entryID int64, in entryDTO) (bool, error) {
if in.AthleteID != nil || len(in.Athletes) == 0 {
return false, nil
}
before, err := entryAthleteIDsQ(ctx, s.ev, entryID)
if err != nil {
return false, err
}
after := entryAthleteIDs(in.Athletes)
if err := replaceEntryAthletes(ctx, s.ev, entryID, in.Athletes); err != nil {
return false, err
}
return !slices.Equal(before, after), nil
}
// entryAthleteIDsQ 读一条报名当前的队员 id(升序),用于判断这次导入有没有改动关联。
func entryAthleteIDsQ(ctx context.Context, q queryer, entryID int64) ([]int64, error) {
rows, err := q.QueryContext(ctx,
`SELECT athlete_id FROM entry_athletes WHERE entry_id = ? ORDER BY athlete_id`, entryID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := []int64{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, internal(err)
}
out = append(out, id)
}
return out, rows.Err()
}
// entryAthleteIDs 把要写进去的队员摊成升序的 id 列表(与 entryAthleteIDsQ 同一种形状好比较)。
func entryAthleteIDs(athletes []entryAthleteDTO) []int64 {
out := make([]int64, 0, len(athletes))
for _, a := range athletes {
out = append(out, a.AthleteID)
}
slices.Sort(out)
return out
}
// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。 // entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。
// //
// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串, // 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串,
+14
View File
@@ -244,8 +244,14 @@ func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 队员(devdocs/20)同理随报名进引擎:每个队员各拿一份完整的个人分(见 domain 的 memberCopy)。
members, err := loadEntryAthletesQ(ctx, q, eventID)
if err != nil {
return nil, err
}
for i := range out { for i := range out {
out[i].TeamShares = shares[out[i].ID] out[i].TeamShares = shares[out[i].ID]
out[i].Athletes = members[out[i].ID]
} }
return out, nil return out, nil
} }
@@ -571,6 +577,11 @@ func resultStatusPtr(s domain.ResultStatus) *string {
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源), // 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。 // 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
// //
// 队员的个人副本(MemberCopy)一律不写回:它们是报名项那一份的复制品,写回等于让「同一条报名
// 的每个队员」轮流改写同一个录入位——团体报名项的 results.rank 是团体手工通道的输入,
// 被队员副本覆盖后,第二次发布会把自动名次当成团体名次再发一遍分(A-1 的同一个坑)。
// 该写的那一份是报名项自己(上面第一条规则已经管着它),一条成绩始终只有一个写入者。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次), // 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。 // 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error { func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
@@ -583,6 +594,9 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 { if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue continue
} }
if rr.MemberCopy {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) { if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
continue continue
} }
+2
View File
@@ -282,6 +282,8 @@ func (s *server) routes(mux *http.ServeMux) {
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。 // 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。 // 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler)) mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
// 队员(devdocs/20):整组替换一条团体报名的队员,改完当场重算并推榜——队员决定谁拿个人分、个人奖牌。
mux.HandleFunc("PUT /api/entries/{id}/athletes", s.write(auth.RoleRecorder, s.updateEntryAthletesHandler))
// 成绩 // 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
@@ -0,0 +1,69 @@
-- 团体报名的「队员身份」(devdocs/20 的实现)。
--
-- 背景:双打这类团体 / 对抗项目,界面本来就能一次勾多个运动员(EntryPicker),但提交时只把
-- 姓名拼成文字写进 entries.member_names —— 系统只知道「这条报名属于某单位」,不知道队里坐着谁,
-- 于是所有配了个人通道的团体项目都给不出个人分、个人奖牌,上不了个人积分榜。
--
-- 表达方式:**队员是报名的人员关联,不是新的一条报名**。一条团体报名 0..N 个队员
-- (0 个 = 只报单位,与升级前逐字节一致)。队员与「队伍」(0025 的 entries.squad)是两个
-- 正交的维度:squad 是「这个单位派的第几支队」,队员是「这支队的队员是谁」,可以叠加。
-- **报名唯一键 (category_id, team_id, squad) WHERE athlete_id IS NULL 一个字都不动**:
-- 队员不参与报名去重(同一个人在同一组别的两条报名里出现是允许的,见 devdocs/20 §五)。
--
-- member_names 保留:它是**显示用的文字名单**(导出、大屏、录入页都读它),老数据靠它,
-- 也是「人没录档案、只登记不关联」的降级形态。人员身份只认这张表。
--
-- 老数据回填(尽力而为,匹配不上就不关联,绝不硬猜):
-- 1) 号码布优先:member_names 里的每一项先按 athletes.bib 认人(0014 有部分唯一索引,
-- 号码布是运动员的唯一标识);
-- 2) 其次「姓名 + 单位」,且**同单位同名的人只有一个**时才认(重名留给人工确认);
-- 3) 其余留空 = 维持升级前的行为(只报单位,上不了个人榜)。
-- 回填只在升级那一刻做一次(写进 schema_migrations),不会在每次启动时重跑。它是
-- INSERT OR IGNORE:万一被重放(schema_migrations 少了一行被补跑),已有的关联——包括人工
-- 确认过的——一行都不会被覆盖或重复,最多补上「还没有的关联」(与 0024 的份额回填同一口径)。
-- 回填只碰**纯团体报名**(athlete_id IS NULL):带运动员的报名本来就带身份,不需要队员。
--
-- 回填之后,配了个人通道(scope=athlete)的老团体项目会开始给队员发个人分 / 个人奖牌
-- ——这正是本单的目的;**单位维度(单位总分、单位奖牌)不受影响**(见 domain 侧的口径与
-- httpapi 的端到端对拍)。只配了团体通道的球类项目(现场绝大多数)一个字节都不变。
CREATE TABLE IF NOT EXISTS entry_athletes (
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
athlete_id INTEGER NOT NULL REFERENCES athletes(id) ON DELETE CASCADE,
-- 展示与流水顺序都用它:写侧按请求给的顺序编号,老数据回填按 member_names 里的书写顺序。
-- 名次与积分不依赖顺序(每个队员各拿一整份),它只影响「队里有谁」的排列。
sort_order INTEGER NOT NULL DEFAULT 0,
-- 同一条报名里同一个人只能出现一次(重复 = 同一条成绩给同一个人记两份分、两枚牌)。
PRIMARY KEY (entry_id, athlete_id)
);
-- 不另建 entry_id 索引:主键 (entry_id, athlete_id) 的索引已经以 entry_id 打头,
-- 读侧的「按小项批量取队员」走的正是它(与 0024 的份额表同一个取舍)。
-- athlete_id 上的级联删除(人员被彻底删除时带走关联行)走全表扫描,但这张表只有报名量级。
-- 回填 1:号码布优先。member_names 里写号码布(导入规范允许)时按号码布认人。
-- json_valid 的守卫是必须的:member_names 在库里是 JSON 数组文本,但历史上不排除被手工写成
-- 别的样子;json_each 遇到坏 JSON 会直接报错,那会让整个升级失败、现场开不了机。
-- 坏值按「没有名单」处理('[]'),等价于不回填这条报名。
INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order)
SELECT e.id, a.id, je.key
FROM entries e
JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je
JOIN athletes a ON a.bib = TRIM(je.value)
WHERE e.athlete_id IS NULL
AND TRIM(COALESCE(je.value, '')) <> '';
-- 回填 2:姓名 + 单位。同单位同名不止一个时(重名)不认——猜错会把分记到另一个人头上,
-- 而人还能事后在界面上补;猜错的分却已经上了榜。
INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order)
SELECT e.id, a.id, MIN(je.key)
FROM entries e
JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je
JOIN athletes a ON a.name = TRIM(je.value) AND a.team_id = e.team_id
WHERE e.athlete_id IS NULL
AND TRIM(COALESCE(je.value, '')) <> ''
-- 上面那条已经按号码布认过人时不再按姓名认第二遍(同一条报名里同一个人只能一行,主键兜着,
-- 这条只是让「认了谁」这件事一眼可读)。
AND NOT EXISTS (SELECT 1 FROM entry_athletes ea WHERE ea.entry_id = e.id AND ea.athlete_id = a.id)
AND (SELECT COUNT(*) FROM athletes a2 WHERE a2.name = TRIM(je.value) AND a2.team_id = e.team_id) = 1
GROUP BY e.id, a.id;
@@ -0,0 +1,242 @@
package event
import (
"database/sql"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0026:团体报名的「队员身份」(entry_athletes)。
//
// 四条底线:
// - 老数据零变化:既有表(entries / results / score_ledger / channel_ranks)一行一列都不改,
// 新增的只有这张关联表;
// - 回填**以号码布为准**、其次「姓名 + 单位」,且只在「同单位同名唯一」时才认,重名与查无此人
// 一律留空(不硬猜:猜错就是把某人的分记到另一个人头上);
// - 只碰纯团体报名(athlete_id IS NULL):带运动员的报名自己就是那个人的身份;
// - 幂等:重复执行迁移(含把这条 SQL 重放一遍)不报错、不重复回填、不覆盖人工改过的关联。
func TestMigration0026EntryAthletes(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0026") // 回到「升级前」:库结构到 0025 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
// 号码布是运动员的唯一标识(0014 上有部分唯一索引)。
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('张伟', '男', 1, 'A001')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A002')`,
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
// 同单位两个同名的人:回填不许猜(下面这条报名里写的「李强」认不出是哪个)。
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
// 另一个单位的运动员:号码布能认到他(号码布不分单位),姓名+单位认不到。
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '女', 2, 'B001')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
// 1) 姓名 + 单位都认得上:张伟 + 王五(顺序也要留住)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (1, 1, 1, '["张伟","王五"]', 'registered')`,
// 2) 号码布优先:写的是 A002(同一个单位的王五)与 B001(另一个单位的赵六)。
// 用队伍名与上一条区分开(一个单位在一个组别只能有一条不分队的团体报名,见 0025)。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
VALUES (1, 1, 1, '["A002","B001"]', 'registered', '二队')`,
// 3) 重名(同单位两个李强):不认。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
VALUES (1, 1, 2, '["李强"]', 'registered')`,
// 4) 查无此人:不认。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
VALUES (1, 1, 2, '["查无此人"]', 'registered', '二队')`,
// 5) 带运动员的报名:本来就有身份,不回填。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
VALUES (1, 1, 1, 1, '["张伟"]', 'registered')`,
// 6) member_names 是坏 JSON(历史上被手工写过非 JSON 文本):按「没有名单」处理,迁移不许失败。
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
VALUES (1, 1, 2, '张伟/李强', 'registered', '三队')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:这张表还不该存在。
if _, err := db.Exec(`SELECT count(*) FROM entry_athletes`); err == nil {
t.Fatal("升级前不应存在 entry_athletes 表")
} else if !strings.Contains(err.Error(), "entry_athletes") {
t.Fatalf("查询报错应点明缺表 entry_athletes,实际 %v", err)
}
// 既有表在升级前的原始内容:升级后必须逐字节一致。
untouched := []string{
`SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0), COALESCE(member_names, ''),
COALESCE(bib, ''), COALESCE(seed, ''), COALESCE(status, ''), squad
FROM entries ORDER BY id`,
}
before := make([]string, len(untouched))
for i, q := range untouched {
before[i] = tableText(t, db, q)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 回填结果:只有「认得出」的两种情形落行,顺序按 member_names 的书写顺序。
want := map[int64][]string{
1: {"1:0", "2:1"}, // 张伟、王五
2: {"2:0", "5:1"}, // 号码布 A002 → 王五;号码布 B001 → 赵六(另一个单位也认)
3: {}, // 重名不猜
4: {}, // 查无此人不猜
5: {}, // 带运动员的报名不回填
6: {}, // 坏 JSON:按没有名单处理
}
got := entryAthletesText(t, db)
for entryID, wantRows := range want {
if strings.Join(got[entryID], ",") != strings.Join(wantRows, ",") {
t.Errorf("报名 %d 回填出 %v,期望 %v", entryID, got[entryID], wantRows)
}
}
// 老数据零变化:既有表的每一行每一个字段都没动。
for i, q := range untouched {
if after := tableText(t, db, q); after != before[i] {
t.Fatalf("升级改动了既有数据(第 %d 条对拍):\n之前\n%s之后\n%s", i+1, before[i], after)
}
}
// 表结构:主键 (entry_id, athlete_id) 保证同一条报名里同一个人只有一行。
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 9)`); err == nil {
t.Error("同一条报名里同一个运动员重复应被主键拒绝")
}
// 外键:报名 / 人员不存在都写不进去。
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (999, 1)`); err == nil {
t.Error("entry_id 外键应拦住不存在的报名")
}
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (1, 999)`); err == nil {
t.Error("athlete_id 外键应拦住不存在的人员")
}
// 幂等:再跑一次迁移,内容不变。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
if after := entryAthletesText(t, db); fmt.Sprint(after) != fmt.Sprint(got) {
t.Fatalf("重复迁移改动了队员关联:\n之前 %v\n之后 %v", got, after)
}
// 迁移被重放(schema_migrations 少一行被补跑)时:已有的关联(含人工确认过的)不被覆盖、
// 不产生重复行(INSERT OR IGNORE 的口径与 0024 的份额回填一致)。
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 7)`); err != nil {
t.Fatalf("人工补一条关联: %v", err)
}
if _, err := db.Exec(readMigration(t, "0026")); err != nil {
t.Fatalf("重放 0026: %v", err)
}
replayed := entryAthletesText(t, db)
if rows := replayed[3]; strings.Join(rows, ",") != "3:7" {
t.Errorf("重放改动了人工确认的关联(顺序必须原样保留):%v", rows)
}
if rows := replayed[1]; strings.Join(rows, ",") != "1:0,2:1" {
t.Errorf("重放后报名 1 的关联应保持不变、也不重复:%v", rows)
}
// 级联:报名删掉带走它的队员行;人员被彻底删除也带走(purgeAthlete 走的就是这条外键)。
if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil {
t.Fatalf("删报名: %v", err)
}
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE entry_id = 2`); got != 0 {
t.Errorf("报名删掉后队员行应级联清空,还剩 %d 条", got)
}
if _, err := db.Exec(`DELETE FROM athletes WHERE id = 5`); err != nil {
t.Fatalf("删人员: %v", err)
}
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE athlete_id = 5`); got != 0 {
t.Errorf("人员删掉后队员行应级联清空,还剩 %d 条", got)
}
// 外键悬挂检查。
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("迁移后存在外键悬挂")
}
// 迁移登记数对得上(这条迁移被记账一次)。
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
}
// entryAthletesText 把队员关联按报名归组导成 "%d:%d"(运动员 id:顺序)的稳定文本。
func entryAthletesText(t *testing.T, db *sql.DB) map[int64][]string {
t.Helper()
out := make(map[int64][]string)
rows, err := db.Query(`SELECT entry_id, athlete_id, sort_order FROM entry_athletes
ORDER BY entry_id, sort_order, athlete_id`)
if err != nil {
t.Fatalf("读队员关联: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
entryID, athleteID, order int64
)
if err := rows.Scan(&entryID, &athleteID, &order); err != nil {
t.Fatalf("扫描队员关联: %v", err)
}
out[entryID] = append(out[entryID], fmt.Sprintf("%d:%d", athleteID, order))
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历队员关联: %v", err)
}
return out
}
// tableText 把一条查询的全部行拼成一份对拍文本(列之间用 | 连)。
func tableText(t *testing.T, db *sql.DB, query string) string {
t.Helper()
rows, err := db.Query(query)
if err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
cols, err := rows.Columns()
if err != nil {
t.Fatalf("读列名: %v", err)
}
var b strings.Builder
for rows.Next() {
cells := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range cells {
ptrs[i] = &cells[i]
}
if err := rows.Scan(ptrs...); err != nil {
t.Fatalf("扫描 %q: %v", query, err)
}
for i, c := range cells {
if i > 0 {
b.WriteString("|")
}
switch v := c.(type) {
case nil:
b.WriteString("<nil>")
case []byte:
b.Write(v)
default:
fmt.Fprintf(&b, "%v", v)
}
}
b.WriteString("\n")
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历 %q: %v", query, err)
}
return b.String()
}
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import type { Athlete, EntryWithResult, ImportConflict } from '@/shared' import type { Athlete, EntryWithResult, ImportConflict } from '@/shared'
import { normalizeSquad, teamSquadLabel } from '@/shared'
import MxIcon from '../MxIcon.vue' import MxIcon from '../MxIcon.vue'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import { submitEntryAthletes } from '../../pages/entryAthletes'
import { useBaseData } from './useBaseData' import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast' import { useToastStore } from '../../stores/toast'
import { import {
@@ -11,6 +13,7 @@ import {
buildTeamSelections, buildTeamSelections,
conflictLabel, conflictLabel,
emptyTeamPick, emptyTeamPick,
planTeamAthleteSync,
registeredAthleteIds, registeredAthleteIds,
registeredTeamIds, registeredTeamIds,
registeredTeamSquads, registeredTeamSquads,
@@ -24,6 +27,7 @@ import {
toCsv, toCsv,
type SelectionMode, type SelectionMode,
type TeamPickState, type TeamPickState,
type TeamSelection,
} from './entrySelection' } from './entrySelection'
/** /**
@@ -393,6 +397,48 @@ async function submitIndividual(): Promise<void> {
} }
} }
/** 一条报名的展示名(单位 + 队名):队员写失败时要点到具体是哪一条。 */
function entrySyncLabel(entryId: number): string {
const entry = entries.value.find((item) => item.id === entryId)
if (entry === undefined) return `报名 ${entryId}`
const team = data.teamById(entry.teamId)
const name = team?.shortName || team?.name || entry.teamCode || `报名 ${entryId}`
return teamSquadLabel(name, normalizeSquad(entry.squad))
}
/**
* 把勾中的队员写回去(`PUT /api/entries/{id}/athletes`,只认 athleteId)。
*
* 报名行本身走的是导入通道:CSV 的 member_names 列只有**姓名文字**,认不出是同名的哪一个人。
* 所以导入成功后再按「组别 + 单位 + 队伍」找回刚写进去的那几条报名,把勾中的人整组挂上去
* ——姓名文字与人员身份并存,没档案的人仍然只在文字名单里。
*
* 这里**不往外抛**:报名已经写进去了,抛给调用方会被说成「加入报名失败」,与事实相反。
* 失败只影响队员关联,单独提示(并且说清去哪补:录入页那一行的队员格还能改)。
*/
async function syncTeamAthletes(selections: readonly TeamSelection[], categoryId: number): Promise<void> {
// 示例数据模式没有真服务端:导入本身也只是内存里的示例数据,写请求发出去只会失败。
if (data.isMock.value) return
const plan = planTeamAthleteSync(selections, entries.value, categoryId)
if (plan.length === 0) return
const failed: string[] = []
for (const item of plan) {
try {
await submitEntryAthletes(item.entryId, item.athletes)
} catch (caught) {
const reason = caught instanceof Error ? caught.message : '队员没有改成功'
failed.push(`${entrySyncLabel(item.entryId)}:${reason}`)
}
}
if (failed.length === 0) return
const listed = failed.slice(0, 3).join(';')
const more = failed.length > 3 ? `;…还有 ${failed.length - 3} 条` : ''
toast.error(
`${failed.length} 条报名的队员关联没存上`,
`${listed}${more}。报名本身已经加入(队员名单的文字已写入),到成绩录入页点这一行的「队员」按钮即可重新挂人。`,
)
}
async function submitTeam(): Promise<void> { async function submitTeam(): Promise<void> {
const event = currentEvent.value const event = currentEvent.value
const category = currentCategory.value const category = currentCategory.value
@@ -409,6 +455,8 @@ async function submitTeam(): Promise<void> {
teams, teams,
}) })
await importSelection(rows, event.id) await importSelection(rows, event.id)
// 导入成功后再挂队员:athleteId 要挂到刚写出来的那条报名上,必须先有报名。
await syncTeamAthletes(teams, category.id)
// 提交成功后清空勾选:已报名的单位会转为「已报名」而不可再勾。 // 提交成功后清空勾选:已报名的单位会转为「已报名」而不可再勾。
resetTeamPick() resetTeamPick()
} catch (caught) { } catch (caught) {
@@ -662,7 +710,11 @@ async function submitTeam(): Promise<void> {
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位, 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位,
队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。 队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。
</p> </p>
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p> <p class="bd-note">
勾中的队员会连同运动员档案一起提交:这条报名挂了谁,配了个人通道的项目就按谁算个人分与个人奖牌。
没勾进名单的人不会关联到人(只在文字名单里留个姓名,个人榜上就看不到他);
证件号、号码布与种子等字段仍通过下方报名表导入填写。
</p>
</template> </template>
<!-- 冲突行既没建报名也没报错,必须在这里说清「哪几行没进、去哪确认」,否则勾了等于白勾。 --> <!-- 冲突行既没建报名也没报错,必须在这里说清「哪几行没进、去哪确认」,否则勾了等于白勾。 -->
@@ -9,11 +9,13 @@
import type { import type {
ConflictAction, ConflictAction,
EntryAthlete,
EntryAthleteInput,
ImportConflict, ImportConflict,
ImportConflictDecision, ImportConflictDecision,
ImportConflictField, ImportConflictField,
} from '@/shared' } from '@/shared'
import { teamSquadLabel } from '@/shared' import { entryRosterHead, teamSquadLabel } from '@/shared'
/** /**
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025), * 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
@@ -220,17 +222,21 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
* *
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字, * 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。 * 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
*
* 「首人」的口径没变(一行只念一个人):有队员关联时它是首名队员 `张伟(A001)`——
* 号码布让人对得上是谁(重名时唯一的线索);没有关联时就是文字名单的第一个人,与升级前逐字一样。
*/ */
export function entryRowLabel(row: { export function entryRowLabel(row: {
athleteName?: string | null athleteName?: string | null
memberNames?: string[] | null memberNames?: string[] | null
athletes?: readonly EntryAthlete[] | null
teamShort?: string | null teamShort?: string | null
teamName?: string | null teamName?: string | null
squad?: string | null squad?: string | null
}): string { }): string {
return ( return (
row.athleteName || row.athleteName ||
row.memberNames?.[0] || entryRosterHead(row) ||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad) teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
) )
} }
@@ -241,9 +247,16 @@ export interface SelectionInput {
teamCode: string teamCode: string
status?: string status?: string
mode: SelectionMode 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[] memberNames?: string[]
} }
@@ -284,15 +297,56 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
eventCode: input.eventCode, eventCode: input.eventCode,
categoryName: input.categoryName, categoryName: input.categoryName,
status, 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 { export interface TeamSelection {
/**
* 单位 id:提交后要靠它(连同组别与队名)找回刚写进去的那条报名,
* 才谈得上把队员关联挂上去。缺省 = 不打算同步队员(例如只想拿这份选举行生成 CSV)。
*/
teamId?: number
teamCode: string teamCode: string
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */ /**
* 队员姓名(导入表的 member_names 列):空数组表示只报单位,名单之后可以再补。
*
* 与 `athletes` **并存**:这是给没录档案的人留的文字名单,也是老数据唯一有的东西。
*/
memberNames: string[] memberNames: string[]
/**
* 队员关联(运动员档案里的人):勾中的人进这里,提交后走 PUT /api/entries/{id}/athletes。
* 缺省 / 空数组 = 这条报名不挂任何队员(只报单位)。
*/
athletes?: EntryAthlete[]
/** /**
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。 * 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。 * 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
@@ -420,26 +474,85 @@ export function selectableTeamIds(
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人, * 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。 * 保证同一份勾选每次生成的 member_names 完全一致。
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。 * 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
*
* 勾中的人同时产出**队员关联**(athleteId,写侧只认它)——姓名文字继续进 member_names
* (导出、大屏、录入页都读它),身份进 athletes:个人分与个人奖牌挂的就是后者。
*/ */
export function buildTeamSelections( export function buildTeamSelections(
state: TeamPickState, state: TeamPickState,
teams: readonly { id: number; code: string }[], 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[] { ): TeamSelection[] {
const picked = new Set(state.teamIds) const picked = new Set(state.teamIds)
const selections: TeamSelection[] = [] const selections: TeamSelection[] = []
for (const team of teams) { for (const team of teams) {
if (!picked.has(team.id)) continue if (!picked.has(team.id)) continue
const memberIds = new Set(state.memberIds[team.id] ?? []) const memberIds = new Set(state.memberIds[team.id] ?? [])
const memberNames = athletes const members = athletes.filter(
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id)) (athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id),
.map((athlete) => athlete.name) )
const memberNames = members.map((athlete) => athlete.name)
const squad = (state.squads?.[team.id] ?? '').trim() 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 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 人)。 */ /** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean { export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
return pickedTeamCount > 0 return pickedTeamCount > 0
+327 -1
View File
@@ -2,11 +2,14 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { import {
entryAthleteText,
eventInputOf, eventInputOf,
formatPoints, formatPoints,
hasAggregateChannel, hasAggregateChannel,
entryRosterText,
isJointTeam, isJointTeam,
jointTeamLabel, jointTeamLabel,
normalizeEntryAthletes,
normalizeSquad, normalizeSquad,
normalizeTeamShares, normalizeTeamShares,
parseResultValue, parseResultValue,
@@ -14,6 +17,7 @@ import {
teamSquadLabel, teamSquadLabel,
type AvgMode, type AvgMode,
type DraftResultInput, type DraftResultInput,
type EntryAthlete,
type EntryStatus, type EntryStatus,
type EntryWithResult, type EntryWithResult,
type Event, type Event,
@@ -68,6 +72,18 @@ import {
squadDraftOf, squadDraftOf,
submitEntrySquad, submitEntrySquad,
} from './entrySquad' } from './entrySquad'
import {
addAthleteDraft,
athleteChoiceRows,
athleteDraftDirty,
athleteDraftIssues,
athleteDraftOf,
athleteDraftPayload,
entryAthletesEditable,
removeAthleteDraft,
submitEntryAthletes,
type AthleteDraftRow,
} from './entryAthletes'
import { import {
entryBatchOutcome, entryBatchOutcome,
entryBatchSizeError, entryBatchSizeError,
@@ -135,6 +151,11 @@ interface EditableRow {
teamShares: TeamShareView[] teamShares: TeamShareView[]
athleteName: string athleteName: string
memberNames: string[] | null memberNames: string[] | null
/**
* 队员(谁在这支队里,devdocs/20):读侧恒为数组,空数组 = 只报单位(老数据的样子)。
* 与 memberNames 并存——文字名单里可能有没录档案的人。
*/
athletes: EntryAthlete[]
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */ /** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
heat: number | null heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */ /** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
@@ -901,6 +922,8 @@ function toRow(entry: EntryWithResult): EditableRow {
}), }),
athleteName: entry.athleteName ?? '', athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames, memberNames: entry.memberNames,
// 老服务端不下发 athletes 时按空数组(= 只报单位),显示与升级前逐字一致。
athletes: normalizeEntryAthletes(entry.athletes),
heat: entry.heat, heat: entry.heat,
entryStatus: entry.status, entryStatus: entry.status,
resultId: result?.id ?? null, resultId: result?.id ?? null,
@@ -1336,6 +1359,161 @@ async function saveShareEditor(): Promise<void> {
} }
} }
// ── 队员(谁在这支队里,devdocs/20) ──────────────────────────────────────────
/**
* 「队员」弹窗:一条**纯团体报名**挂着哪些运动员。
*
* 为什么单开一个弹窗、入口放在姓名格旁边,而不是塞进「成员单位与份额」那个弹窗:
* - 份额管的是「这条成绩分给哪些单位」,队员管的是「这条报名里有哪几个人」,是两条正交的线,
* 两个都是**整组替换**的写请求;塞进一个弹窗就得让一个保存按钮同时对应两张表;
* - 入口贴着「运动员 / 队」那一列 —— 队里坐着谁本来就写在这一格上,单位格已经归份额与联队了;
* - 常规流程不受影响:只有纯团体行(没有运动员的那种)才有这个按钮,
* 队员为空(老数据)时表格里的文案一个字符都没变。
*/
interface AthleteEditor {
entryId: number
/** 展示用的报名身份:姓名 / 队员名单 + 号码布,与成员单位弹窗同一口径。 */
label: string
/** 这条报名的主单位:候选队员只看本单位的人(名单里已有的跨单位队员仍然看得见)。 */
teamId: number
teamName: string
original: AthleteDraftRow[]
draft: AthleteDraftRow[]
/** 候选搜索原文:队员多的单位靠它找人。 */
search: string
saving: boolean
error: string | null
}
const athleteEditor = ref<AthleteEditor | null>(null)
/** 候选(本单位启用中的运动员)+ 名单里已有的(含已停用 / 跨单位),勾选状态跟着草稿走。 */
const athleteEditorChoices = computed(() => {
const editor = athleteEditor.value
if (editor === null) return []
const keyword = editor.search.trim().toLowerCase()
const rows = athleteChoiceRows(baseData.athletes.value, editor.draft, editor.teamId)
if (keyword === '') return rows
return rows.filter(
(row) =>
row.name.toLowerCase().includes(keyword) ||
row.employeeNo.toLowerCase().includes(keyword) ||
(row.bib ?? '').toLowerCase().includes(keyword),
)
})
const athleteEditorCount = computed(() => athleteEditor.value?.draft.length ?? 0)
const athleteEditorDirty = computed(() => {
const editor = athleteEditor.value
return editor === null ? false : athleteDraftDirty(editor.draft, editor.original)
})
const athleteEditorIssues = computed(() => athleteDraftIssues(athleteEditor.value?.draft ?? []))
const canSaveAthletes = computed(
() =>
canWrite.value &&
athleteEditor.value !== null &&
!athleteEditor.value.saving &&
athleteEditorDirty.value &&
athleteEditorIssues.value.length === 0,
)
/** 队员还没存上时这一行在表格里长什么样:提示里念一遍,改完一眼能核对。 */
const athletePreviewText = computed(() => {
const editor = athleteEditor.value
if (editor === null) return ''
const text = editor.draft.map(entryAthleteText).join('、')
return text === '' ? '(无队员,只报单位)' : text
})
/** 姓名格旁边那个按钮的悬停提示:把「点了会怎样」说清楚。 */
function athleteCellTitle(row: EditableRow): string {
const head = athleteCellName(row)
return head === '—'
? '点击登记这条报名的队员(个人分与个人奖牌按队员算)'
: `${head}|点击修改这条报名的队员(个人分与个人奖牌按队员算)`
}
async function openAthleteEditor(row: EditableRow): Promise<void> {
athleteEditor.value = {
entryId: row.entryId,
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
teamId: row.teamId,
teamName: row.teamName,
original: athleteDraftOf(row),
draft: athleteDraftOf(row),
search: '',
saving: false,
error: null,
}
// 候选队员来自基础数据的运动员名单;录入页平时不加载这一份,打开时补一次(已加载时不会重复请求)。
await baseData.load()
}
function closeAthleteEditor(): void {
athleteEditor.value = null
}
/**
* 勾上 / 取消一名队员;草稿的顺序就是「队里有谁」的排列,写进去的 sort_order 跟着它。
*
* 参数类型写成 `globalThis.Event`:这个文件里的 `Event` 是小项(`@/shared` 的 Event),
* 直接写 `Event` 会把 DOM 事件类型遮住。
*/
function onAthleteChoice(
athleteId: number,
name: string,
bib: string | null,
event: globalThis.Event,
): void {
const editor = athleteEditor.value
const input = event.target
if (editor === null || !(input instanceof HTMLInputElement)) return
editor.draft = input.checked
? addAthleteDraft(editor.draft, { id: athleteId, name, bib })
: removeAthleteDraft(editor.draft, athleteId)
}
function editAthleteSearch(event: InputEvent): void {
const editor = athleteEditor.value
const input = event.target
if (editor === null || !(input instanceof HTMLInputElement)) return
editor.search = input.value
}
/**
* 保存队员(整组替换),**不整表重拉**:队员只决定这条报名的分记给谁,
* 不影响任何一行的成绩与名次(重拉会把没保存的成绩编辑一并覆盖掉)。
* 服务端在同一事务里重算本组名次与流水,所以保存后榜单已经是新口径。
*/
async function saveAthleteEditor(): Promise<void> {
const editor = athleteEditor.value
if (editor === null || !canSaveAthletes.value) return
if (data.isMock) {
const notice = sampleDataBlockedNotice('保存队员')
toast.error(notice.title, notice.detail)
return
}
editor.saving = true
editor.error = null
const payload = athleteDraftPayload(editor.draft)
const row = allRows.value.find((item) => item.entryId === editor.entryId)
try {
const outcome = await submitEntryAthletes(editor.entryId, payload)
// 服务端回显优先(它按 sort_order 排过、姓名与号码布是库里的);响应里没有时按手里的草稿。
const saved = outcome.athletes ?? editor.draft
if (row !== undefined) row.athletes = saved
closeAthleteEditor()
void data.refreshStandings()
if (row !== undefined) {
toast.success('队员已保存', `${teamCellText(row)}:${athleteCellName(row)}(本组已重算,大屏榜单会跟着刷新)`)
}
} catch (caught) {
editor.error = caught instanceof Error ? caught.message : '保存队员失败'
} finally {
editor.saving = false
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ──────────────────────── // ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */ /** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
@@ -1756,6 +1934,9 @@ function batchRows(): EntryBatchRow[] {
return selectedRows.value.map((row) => ({ return selectedRows.value.map((row) => ({
entryId: row.entryId, entryId: row.entryId,
name: rowLabel(row), name: rowLabel(row),
// 有队员关联的团体行在确认框里念**全名单**:退赛 / 移出报名动的是队里的每一个人,
// 只念首人会漏掉后面的人。没有队员关联时留空,确认框的文案与升级前逐字一致。
members: row.athletes.length > 0 ? (entryRosterText(row) ?? '') : '',
bib: row.bib, bib: row.bib,
// 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。 // 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。
team: teamCellText(row), team: teamCellText(row),
@@ -2551,7 +2732,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</span> </span>
</td> </td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 --> <!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td> <td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span>
<!--
队员(devdocs/20):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。
老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。
-->
<button
v-if="entryAthletesEditable(row)"
class="mx-btn mx-btn-ghost mx-btn-sm athlete-edit"
type="button"
:title="athleteCellTitle(row)"
@click="openAthleteEditor(row)"
>
队员
</button>
</td>
<td class="result-cell"> <td class="result-cell">
<!-- <!--
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击, 已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
@@ -3039,6 +3234,102 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div> </div>
</div> </div>
<!--
队员(谁在这支队里,devdocs/20):纯团体报名的姓名格旁边那个「队员」按钮打开。
默认摆出来的就是这条报名当前的队员——老数据(没有队员关联)打开是空名单,
不勾人的话保存按钮是灰的,点「关闭」不会发出任何请求,常规流程一点没被碰到。
-->
<div v-if="athleteEditor !== null" class="mx-modal-overlay open" @click.self="closeAthleteEditor">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">队员(谁在这支队里)</span>
<button class="mx-modal-close" type="button" @click="closeAthleteEditor">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="share-hint">
队员是<strong>人员关联</strong>:勾上的每个人各拿<strong>一整份</strong>个人分、各得<strong>一枚</strong>
个人奖牌(配了个人通道的项目才算),号码布是运动员的唯一标识,所以这里显示成「姓名(号码布)」——
光写姓名遇到重名会认错人。
</p>
<p class="bd-note">
正在修改:{{ athleteEditor.label }}({{ athleteEditor.teamName }});
一个都不勾 = 只报单位,与升级前完全一样。
</p>
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
<div v-if="!canWrite" class="share-readonly">
<MxIcon name="lock" :size="14" />
<span>{{ auth.readOnly ? '当前为访客只读视图,队员只能查看。' : '服务端未连接,暂时不能修改队员。' }}</span>
</div>
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input
class="mx-input"
:value="athleteEditor.search"
:disabled="!canWrite || athleteEditor.saving"
placeholder="搜索姓名 / 工号 / 号码布"
@input="editAthleteSearch"
/>
</div>
</div>
<div class="bd-toolbar-right">
<span class="bd-note">已选 {{ athleteEditorCount }} 人</span>
</div>
</div>
<!-- 人员名单读不出来时候选必然是空的,得说清是「读不到」而不是「这个单位没人」。 -->
<div v-if="baseData.error.value !== null" class="mx-badge mx-badge-warning share-error">
运动员名单没读出来({{ baseData.error.value }}):候选是空的,名单里已有的队员仍可查看与移除。
</div>
<div class="athlete-choice-list">
<label
v-for="choice in athleteEditorChoices"
:key="choice.athleteId"
class="bd-check athlete-choice-row"
>
<input
type="checkbox"
:checked="choice.picked"
:disabled="!canWrite || athleteEditor.saving"
@change="onAthleteChoice(choice.athleteId, choice.name, choice.bib, $event)"
/>
<span class="athlete-choice-name">{{ entryAthleteText(choice) }}</span>
<span class="bd-note">{{ choice.employeeNo || '—' }}</span>
<span v-if="choice.note !== ''" class="mx-badge mx-badge-neutral">{{ choice.note }}</span>
</label>
<div v-if="athleteEditorChoices.length === 0" class="bd-note athlete-choice-empty">
{{ athleteEditor.search.trim() === '' ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
</div>
</div>
<ul v-if="athleteEditorIssues.length > 0" class="issue-list">
<li v-for="(message, index) in athleteEditorIssues" :key="index" class="error">
<MxIcon name="warning" :size="14" />
<span>{{ message }}</span>
</li>
</ul>
<div v-if="athleteEditor.error !== null" class="mx-badge mx-badge-error share-error">
{{ athleteEditor.error }}
</div>
<p class="bd-note">改完这一行是:<strong>{{ athletePreviewText }}</strong></p>
<p class="bd-note">
保存后本组的名次与积分会当场重算(个人分与个人奖牌会跟着换人),大屏榜单跟着刷新。
没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。
</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="closeAthleteEditor">
{{ athleteEditorDirty ? '放弃修改' : '关闭' }}
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveAthletes" @click="saveAthleteEditor">
<MxIcon name="check" :size="14" />
{{ athleteEditor.saving ? '保存中…' : '保存队员' }}
</button>
</div>
</div>
</div>
<ConfirmDialog <ConfirmDialog
:open="unpublishDialog" :open="unpublishDialog"
tone="danger" tone="danger"
@@ -3900,6 +4191,41 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px; font-size: 11px;
color: var(--mx-t3); color: var(--mx-t3);
} }
/* 「队员」入口:紧跟在队名单后面,弱化成一个小按钮,不抢姓名与成绩的位置。 */
.athlete-edit {
margin-left: 6px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* 队员候选列表:与补报名里的队员勾选同一套观感(那套是 EntryPicker 的 scoped 样式,
作用不到这里,所以在这边按同一份排版自己写一份)。 */
.athlete-choice-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
max-height: 320px;
overflow-y: auto;
margin-bottom: 12px;
}
.athlete-choice-row {
justify-content: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.athlete-choice-name {
font-size: 13px;
color: var(--mx-text);
}
.athlete-choice-row .bd-note {
margin-left: auto;
}
.athlete-choice-empty {
grid-column: 1 / -1;
padding: 12px 0;
}
.hotkey-hint { .hotkey-hint {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
ApiError, ApiError,
entryRosterText,
formatDateTime, formatDateTime,
type EntryWithResult, type EntryWithResult,
type Event, type Event,
@@ -121,7 +122,8 @@ const diffRows = computed<RevisionDiffRow[]>(() => {
entryId: entry.entryId, entryId: entry.entryId,
kind: entry.kind, kind: entry.kind,
teamLabel: teamId === null ? '—' : teamLabel(teamId), teamLabel: teamId === null ? '—' : teamLabel(teamId),
athleteLabel: info?.athleteName ?? info?.memberNames?.join('、') ?? '', // 团体行有队员关联时写「姓名(号码布)」;没有关联时与升级前逐字一样(文字名单)。
athleteLabel: info?.athleteName ?? entryRosterText(info ?? {}) ?? '',
beforeText: describeRow(entry.before), beforeText: describeRow(entry.before),
afterText: describeRow(entry.after), afterText: describeRow(entry.after),
beforePoints, beforePoints,
+3 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
ApiError, ApiError,
entryRosterText,
formatDateTime, formatDateTime,
formatPoints, formatPoints,
type Event, type Event,
@@ -350,7 +351,8 @@ async function loadSuspected(): Promise<void> {
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—', categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
entryId: entry.id, entryId: entry.id,
teamName: teamLabel(entry.teamId), teamName: teamLabel(entry.teamId),
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—', // 团体行有队员关联时写「姓名(号码布)」;没有关联时与升级前逐字一样(文字名单)。
athleteName: entry.athleteName ?? entryRosterText(entry) ?? '—',
resultText: result.resultText, resultText: result.resultText,
resultValue: value, resultValue: value,
level: best.level, level: best.level,
+11 -2
View File
@@ -2,18 +2,22 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
ApiError, ApiError,
entryRosterText,
formatPoints, formatPoints,
isJointTeam, isJointTeam,
jointTeamLabel, jointTeamLabel,
normalizeEntryAthletes,
normalizeSquad, normalizeSquad,
normalizeTeamShares, normalizeTeamShares,
teamSquadLabel, teamSquadLabel,
type EntryAthlete,
type EntryWithResult, type EntryWithResult,
type Event, type Event,
type EventCategory, type EventCategory,
type ResultStatus, type ResultStatus,
type TeamShareView, type TeamShareView,
} from '@/shared' } from '@/shared'
import { athleteCellName } from './entryRowDisplay'
import MxIcon from '../components/MxIcon.vue' import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ConfirmDialog from '../components/ConfirmDialog.vue'
@@ -37,6 +41,8 @@ interface DraftRow {
teamShares: TeamShareView[] teamShares: TeamShareView[]
athleteName: string athleteName: string
memberNames: string[] | null memberNames: string[] | null
/** 队员(谁在这支队里,devdocs/20):空数组 = 只报单位,显示与升级前逐字一致。 */
athletes: EntryAthlete[]
resultText: string resultText: string
rank: number | null rank: number | null
status: ResultStatus status: ResultStatus
@@ -174,7 +180,8 @@ function rowLabel(row: DraftRow, index: number): string {
// 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。 // 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。
// teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。 // teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。
const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad) const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad)
const name = row.athleteName || row.memberNames?.join('、') || team || `#${row.entryId}` // 有队员关联时念的是「姓名(号码布)」;没有关联时就是原来的文字名单,逐字一样。
const name = row.athleteName || entryRosterText(row) || team || `#${row.entryId}`
return `第 ${index + 1} 行 ${name}` return `第 ${index + 1} 行 ${name}`
} }
@@ -292,6 +299,8 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
}), }),
athleteName: entry.athleteName ?? '', athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames, memberNames: entry.memberNames,
// 老服务端不下发 athletes 时按空数组(= 只报单位),显示与升级前逐字一致。
athletes: normalizeEntryAthletes(entry.athletes),
resultText: entry.result?.resultText ?? '', resultText: entry.result?.resultText ?? '',
rank: entry.result?.rank ?? null, rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok', status: entry.result?.status ?? 'ok',
@@ -660,7 +669,7 @@ watch(
<!-- 单单位:填了队名就拼在单位名后面(不加括号),没填时与改造前逐字一样。 --> <!-- 单单位:填了队名就拼在单位名后面(不加括号),没填时与改造前逐字一样。 -->
<span v-else class="team-cell">{{ teamCellText(row) }}</span> <span v-else class="team-cell">{{ teamCellText(row) }}</span>
</td> </td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td> <td>{{ athleteCellName(row) }}</td>
<td class="mono">{{ row.resultText || '—' }}</td> <td class="mono">{{ row.resultText || '—' }}</td>
<td class="center mono">{{ row.rank ?? '—' }}</td> <td class="center mono">{{ row.rank ?? '—' }}</td>
<td class="center">{{ statusLabel(row.status) }}</td> <td class="center">{{ statusLabel(row.status) }}</td>
+279
View File
@@ -0,0 +1,279 @@
import { ApiError, normalizeEntryAthletes } from '@/shared'
import type { ApiErrorCode, Athlete, Entry, EntryAthlete, EntryAthleteInput } from '@/shared'
import { entrySquadEditable } from './entrySquad'
/**
* 团体报名的**队员**(谁在这支队里,devdocs/20)的编辑模型与写请求。
*
* 改队员过去没有入口:报完名以后队里坐着谁只能靠导入报名表再写一遍,写进去的还只是姓名文字。
* 现在队员是人员关联,因此它必须能改——换人、补人、减人、清空都对应一场比赛的现实。
*
* 网络层直接 fetch 而不是走 adminApi:与 entryShares.ts / entrySquad.ts 同一套理由
* (请求构造与校验收在一个模块里,能直接单测),CSRF 与 X-Request-Id 的补法也必须与
* shared/api.ts 的写请求保持一致,否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
*
* 编辑模型只做两件事:把这条报名**当前的队员**原样摆出来(没动过就一个请求都不发),
* 以及把草稿换算成只带 athleteId 的写侧载荷。
*/
/** 与 server/internal/httpapi/entry_athletes.go 的 maxEntryAthletes 一致。 */
export const MAX_ENTRY_ATHLETES = 30
export function entryAthletesPath(entryId: number): string {
return `/api/entries/${entryId}/athletes`
}
/**
* 这条报名的队员能不能改:只对**纯团体报名**(没有运动员的那种)放开。
*
* 与服务端同口径,也与队名(entrySquadEditable)同一条线:个人报名按人报,它的身份就是
* 那一个运动员,再挂队员等于「这条报名既是一个人又是一支队」,服务端会 400。
* 界面上先拦住,别让操作员对着一个注定失败的弹窗白点一次保存。
*/
export function entryAthletesEditable(row: { athleteId: number | null }): boolean {
return entrySquadEditable(row)
}
/**
* 编辑器里的一行:一名队员。
*
* 只存展示够用的三样:athleteId(唯一身份,写侧只发它)、姓名与号码布(上屏用)。
* 号码布空着就是没有——不编一个假的出来。
*/
export interface AthleteDraftRow {
athleteId: number
name: string
bib: string | null
}
/** 报名行里够拼出编辑器初值的那几列(只有队员关联,别的都不是编辑模型的输入)。 */
export interface AthleteSourceLike {
athletes?: readonly EntryAthlete[] | null
}
/** 打开编辑器时的初值:这条报名当前的队员(读侧已按 athleteId 去重、号码布去空白)。 */
export function athleteDraftOf(row: AthleteSourceLike): AthleteDraftRow[] {
return normalizeEntryAthletes(row.athletes).map((athlete) => ({
athleteId: athlete.athleteId,
name: athlete.name,
bib: athlete.bib,
}))
}
/** 编辑器 → 写侧载荷:**只认 athleteId**(姓名 / 号码布是服务端读回来的展示字段)。 */
export function athleteDraftPayload(draft: readonly { athleteId: number }[]): EntryAthleteInput[] {
return draft.map((row) => ({ athleteId: row.athleteId }))
}
/** 有没有改动:按 athleteId 的**顺序与集合**比——顺序会写进 sort_order,队里的排列跟着它。 */
export function athleteDraftDirty(
draft: readonly AthleteDraftRow[],
original: readonly AthleteDraftRow[],
): boolean {
if (draft.length !== original.length) return true
for (let i = 0; i < draft.length; i++) {
if (draft[i]?.athleteId !== original[i]?.athleteId) return true
}
return false
}
/**
* 编辑器当前的校验问题(能直接上屏的中文)。
*
* 服务端都会再判一次并用自己的话回 400;前端先判一遍只是为了让操作员**在点保存之前**
* 就看到哪里不对。空名单是**合法值**(=只报单位,与升级前一致),所以这里不是「至少要一个人」。
*/
export function athleteDraftIssues(draft: readonly AthleteDraftRow[]): string[] {
const issues: string[] = []
if (draft.length > MAX_ENTRY_ATHLETES) {
issues.push(`一条报名的队员最多 ${MAX_ENTRY_ATHLETES} 人,当前有 ${draft.length} 人`)
}
const seen = new Set<number>()
for (const row of draft) {
if (seen.has(row.athleteId)) {
issues.push(`「${row.name}」出现了两次:同一条报名里一个人只能占一个位置`)
}
seen.add(row.athleteId)
}
return issues
}
/** 候选 / 已选名单里的一行:勾选状态 + 这条报名已有的队员。 */
export interface AthleteChoiceRow extends AthleteDraftRow {
/** 工号 / 学号:同单位同名时唯一的可见线索。 */
employeeNo: string
/**
* 这一行为什么不在候选里(`已停用` / `其他单位` / `档案里查不到`);候选里的人是空串。
*
* 名单里已有的队员不能因为「不在候选里」就悄悄消失或显示成停用——借调来的人还在打球、
* 档案被删掉的人也已经在这条报名上,标错一句话操作员就会以为人没了、顺手把人清掉。
*/
note: string
picked: boolean
}
/**
* 弹窗里那份可勾选的名单。
*
* 候选 = 该**本单位的启用中运动员**(与补报名里选队员同一口径:报名挂在单位名下);
* 名单里已经有的队员一律列出来,哪怕人已被停用、已经换了单位、档案都已经查不到
* ——从名单里凭空消失,操作员会以为人丢了,改完保存还会顺手把人清掉。
*/
export function athleteChoiceRows(
athletes: readonly Athlete[],
draft: readonly AthleteDraftRow[],
teamId: number,
): AthleteChoiceRow[] {
const byId = new Map(athletes.map((athlete) => [athlete.id, athlete]))
const rows: AthleteChoiceRow[] = []
const listed = new Set<number>()
for (const athlete of athletes) {
if (athlete.teamId !== teamId || !athlete.active) continue
listed.add(athlete.id)
rows.push({
athleteId: athlete.id,
name: athlete.name,
bib: athlete.bib,
employeeNo: athlete.employeeNo ?? '',
note: '',
picked: draft.some((row) => row.athleteId === athlete.id),
})
}
for (const row of draft) {
if (listed.has(row.athleteId)) continue
const athlete = byId.get(row.athleteId)
rows.push({
athleteId: row.athleteId,
name: row.name === '' ? (athlete?.name ?? '') : row.name,
bib: row.bib,
employeeNo: athlete?.employeeNo ?? '',
note: athlete === undefined ? '档案里查不到' : athlete.active ? '其他单位' : '已停用',
picked: true,
})
}
return rows
}
/** 加一名队员;已经在名单里的原样返回(不改顺序,也不重复)。 */
export function addAthleteDraft(
draft: readonly AthleteDraftRow[],
athlete: Pick<Athlete, 'id' | 'name' | 'bib'>,
): AthleteDraftRow[] {
if (draft.some((row) => row.athleteId === athlete.id)) return [...draft]
return [...draft, { athleteId: athlete.id, name: athlete.name, bib: athlete.bib }]
}
export function removeAthleteDraft(
draft: readonly AthleteDraftRow[],
athleteId: number,
): AthleteDraftRow[] {
return draft.filter((row) => row.athleteId !== athleteId)
}
const KNOWN_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
const safe: ApiErrorCode =
typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
return new ApiError(safe, message, status, detail)
}
function readCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
}
function defaultRequestId(): string {
const cryptoApi: Crypto | undefined = globalThis.crypto
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
interface EntryAthletesResult {
ok: boolean
data?: unknown
error?: unknown
}
export interface EntryAthletesOutcome {
entry: Entry | null
/** 服务端回显的队员;老服务端 / 响应里没有时为空,调用方退回手里的草稿。 */
athletes: EntryAthlete[] | null
}
/**
* 整组替换一条报名的队员(空数组 = 清空,只报单位)。
*
* 写操作不降级到示例数据:示例数据模式下的「保存成功」会让操作员以为分已经改好了,
* 页面必须先看 data.isMock 再决定是否调用这里。
*
* 校验失败(400)时服务端回的是**给人看的中文**(个人报名不能挂队员 / 人数超限 / 队员不存在…),
* 这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
*/
export async function submitEntryAthletes(
entryId: number,
athletes: readonly EntryAthleteInput[],
requestId?: string,
): Promise<EntryAthletesOutcome> {
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
// 两处各生成一个就永远命中不了缓存,超时重试会把同一次改动落两遍。
const key = requestId ?? defaultRequestId()
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': key,
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
let response: Response
try {
response = await fetch(entryAthletesPath(entryId), {
method: 'PUT',
headers,
credentials: 'include',
body: JSON.stringify({ athletes: athleteDraftPayload(athletes), requestId: key }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,队员没有改成功', 0, caught)
}
let body: unknown
try {
body = await response.json()
} catch (caught) {
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
}
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
}
const payload = body as unknown as EntryAthletesResult
if (payload.ok !== true) {
const error = isRecord(payload.error) ? payload.error : {}
const message =
typeof error['message'] === 'string' ? error['message'] : `保存队员失败(HTTP ${response.status})`
throw apiError(error['code'], message, response.status, error['detail'])
}
const data = isRecord(payload.data) ? (payload.data as unknown as Entry) : null
return {
entry: data,
athletes: data === null ? null : normalizeEntryAthletes(data.athletes),
}
}
+15 -2
View File
@@ -56,6 +56,13 @@ export interface EntryBatchRow {
entryId: number entryId: number
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */ /** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
name: string name: string
/**
* 这条报名挂着哪些队员(`张伟(A001)、李强(A002)`);没有队员关联时缺省 / 空串。
*
* 「移出报名」「退赛」动的是**队里的每一个人**,只念首人会让操作员看不出还有谁在里面;
* 没有队员关联(老数据 / 只报单位)时留空,确认框的文案与升级前逐字一致。
*/
members?: string
bib: string | null bib: string | null
team: string team: string
categoryId: number categoryId: number
@@ -118,9 +125,15 @@ export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus
return `当前状态为「${status}」,跳过` return `当前状态为「${status}」,跳过`
} }
/** 行在确认框里的身份行:姓名|号码布|单位。 */ /**
* 行在确认框里的身份行:谁|号码布|单位。
*
* 「谁」有队员关联时写全名单(队里的每个人都在这条报名上),没有关联时就是原来的展示名——
* 老数据的这一行一个字都没变。
*/
function personLine(row: EntryBatchRow): string { function personLine(row: EntryBatchRow): string {
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}` const who = row.members !== undefined && row.members !== '' ? row.members : row.name || '(未署名)'
return `${who}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
} }
/** /**
+16 -3
View File
@@ -6,15 +6,28 @@
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。 * 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*/ */
import { entryRosterText } from '@/shared'
import type { EntryAthlete } from '@/shared'
export interface EntryNameLike { export interface EntryNameLike {
athleteName: string athleteName: string
/** 团体 / 接力行的队员名单;个人行为 null。 */ /** 团体 / 接力行的队员名单(文字);个人行为 null。 */
memberNames: string[] | null memberNames: string[] | null
/**
* 团体 / 接力行的**队员关联**(devdocs/20);没有关联时缺省 / 空数组。
* 有它时按「姓名(号码布)」列出来——号码布才是运动员的唯一标识,重名也分得开。
*/
athletes?: readonly EntryAthlete[] | null
} }
/** 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。 */ /**
* 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。
*
* 有队员关联时列成 `张伟(A001)、李强(A002)`,文字名单里没档案的人接在后面;
* 没有关联(老数据 / 只报单位)时就是 memberNames 的拼法,与升级前逐字一样。
*/
export function athleteCellName(row: EntryNameLike): string { export function athleteCellName(row: EntryNameLike): string {
return row.athleteName || row.memberNames?.join('、') || '—' return row.athleteName || entryRosterText(row) || '—'
} }
/** /**
+471
View File
@@ -0,0 +1,471 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { ApiError } from '@/shared'
import type { Athlete } from '@/shared'
import {
entryAthleteText,
entryRosterHead,
entryRosterText,
normalizeEntryAthletes,
} from '@/shared'
import {
REQUIRED_COLUMNS,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
planTeamAthleteSync,
toggleMemberPick,
toggleTeamPick,
} from '../components/baseData/entrySelection'
import { planEntryBatch } from '../pages/entryBatch'
import { athleteCellName } from '../pages/entryRowDisplay'
import {
MAX_ENTRY_ATHLETES,
addAthleteDraft,
athleteChoiceRows,
athleteDraftDirty,
athleteDraftIssues,
athleteDraftOf,
athleteDraftPayload,
entryAthletesEditable,
entryAthletesPath,
removeAthleteDraft,
submitEntryAthletes,
} from '../pages/entryAthletes'
import type { AthleteDraftRow } from '../pages/entryAthletes'
/**
* 团体报名的「队员身份」(devdocs/20)。界面这一侧要钉住的是:
* 1. 勾了人就把 **athleteId** 一起提交上去(姓名文字不再是唯一信息);
* 2. member_names 的文字名单原样保留 —— 没档案的人只能留文字,这条路不能丢;
* 3. 已存在的团体报名能改队员,走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键;
* 4. 任何显示队员的地方都念成「姓名(号码布)」;**没有队员关联的老数据一个字符都不变**。
*/
const BIB = (id: number, name: string, bib: string | null) => ({ athleteId: id, name, bib })
// ── 显示:有队员 / 没队员两种 ────────────────────────────────────────────────
test('没有队员关联(老数据)时,队名单的显示与现在逐字一样', () => {
assert.equal(entryRosterText({ memberNames: ['王芳', '刘洋'] }), '王芳、刘洋')
assert.equal(entryRosterText({ memberNames: [] }), '')
assert.equal(entryRosterText({ memberNames: null }), null)
assert.equal(entryRosterText({}), null)
assert.equal(entryRosterText({ athletes: [], memberNames: ['王芳'] }), '王芳')
// 老服务端根本不下发这个字段:一样当「没有队员」。
assert.equal(entryRosterText({ athletes: null, memberNames: ['王芳'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: ['王芳', '刘洋'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: null }), '')
assert.equal(entryRosterHead({}), '')
// 表格姓名列与报错行名的回落口径都一个字没变。
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
assert.equal(entryRowLabel({ memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
})
test('有队员关联时显示「姓名(号码布)」;没有号码布的人只写姓名', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
assert.equal(entryAthleteText(BIB(11, '张伟', ' A001 ')), '张伟(A001)', '号码布去掉两侧空白')
assert.equal(entryAthleteText(BIB(12, '李强', null)), '李强')
assert.equal(entryAthleteText(BIB(12, '李强', ' ')), '李强')
assert.equal(entryRosterText({ athletes }), '张伟(A001)、李强')
// 文字名单与队员是同一批人时不重复列一遍(勾选报名写出来的正是这种情况)。
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '李强'] }), '张伟(A001)、李强')
assert.equal(entryRosterHead({ athletes }), '张伟(A001)')
assert.equal(athleteCellName({ athleteName: '', memberNames: null, athletes }), '张伟(A001)、李强')
assert.equal(entryRowLabel({ athleteName: '', memberNames: null, athletes, teamShort: '一司' }), '张伟(A001)')
})
// 没档案的人(临时借调、没录档案):只能留在文字名单里,显示也要看得见。
test('队员关联与文字名单并存:没有档案的人仍留在文字名单里', () => {
const athletes = [BIB(11, '张伟', 'A001')]
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '临时工小王'] }), '张伟(A001)、临时工小王')
assert.equal(entryRosterText({ athletes: [], memberNames: ['临时工小王'] }), '临时工小王')
assert.equal(entryRosterText({ athletes, memberNames: null }), '张伟(A001)')
})
test('normalizeEntryAthletes:恒为数组、丢脏行、按 athleteId 去重、号码布归一', () => {
assert.deepEqual(normalizeEntryAthletes(undefined), [])
assert.deepEqual(normalizeEntryAthletes(null), [])
assert.deepEqual(normalizeEntryAthletes('x'), [])
assert.deepEqual(
normalizeEntryAthletes([
{ athleteId: 11, name: '张伟', bib: ' A001 ' },
{ athleteId: 11, name: '张伟', bib: 'A001' },
{ athleteId: 0, name: '没有 id' },
{ name: '缺 athleteId' },
null,
]),
[BIB(11, '张伟', 'A001')],
)
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12, name: '李强' }]), [BIB(12, '李强', null)])
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12 }]), [BIB(12, '', null)])
})
// ── 补报名 / 团体报名:勾了人就把 athleteId 带上 ──────────────────────────────
const ATHLETES = [
{ id: 11, teamId: 1, name: '张伟', bib: 'A001', active: true },
{ id: 12, teamId: 1, name: '李强', bib: null, active: true },
{ id: 13, teamId: 1, name: '王芳', bib: 'A003', active: false },
{ id: 21, teamId: 2, name: '刘洋', bib: 'B001', active: true },
]
test('勾了人:buildTeamSelections 把 athleteId 带上,memberNames 仍是姓名文字', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 13, true)
const selections = buildTeamSelections(state, teams, ATHLETES)
assert.equal(selections[0]?.teamId, 1, '带上单位 id:提交后要靠它找回刚写进去的那条报名')
assert.deepEqual(selections[0]?.memberNames, ['张伟', '李强'], '姓名文字按列表顺序,与改造前一样')
assert.deepEqual(selections[0]?.athletes, [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)], '停用的人不进名单也不进关联')
assert.deepEqual(selections[1], {
teamId: 2,
teamCode: 'F2',
memberNames: [],
athletes: [],
}, '一个都没勾的单位没有队员关联(=只报单位)')
})
test('队员进的是人员关联,不是 CSV:member_names 仍是姓名文字、列数不变', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)],
},
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
assert.deepEqual(rows[1], [
'BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '张伟/李强', '',
])
})
test('没档案的人仍走文字名单:队员关联为空时,姓名照样写进 member_names', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [{ teamCode: 'F1', memberNames: ['临时工小王'], athletes: [] }],
})
assert.equal(rows[1]?.[11], '临时工小王', '文字名单是没档案的人唯一的登记方式')
assert.deepEqual(rows[1]?.slice(12), [''], '队伍列照旧留空')
})
test('planTeamAthleteSync:按 组别+单位+队伍 找到刚提交的那条报名,只发 athleteId', () => {
const selections = [
{ teamId: 1, teamCode: 'F1', memberNames: ['张伟'], athletes: [BIB(11, '张伟', 'A001')] },
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
{ teamId: 3, teamCode: 'F3', memberNames: ['王芳'], squad: '二队', athletes: [BIB(31, '王芳', null)] },
{ teamId: 4, teamCode: 'F4', memberNames: [], athletes: [] },
]
const entries = [
{ id: 101, categoryId: 7, teamId: 2, squad: '', athletes: [BIB(21, '刘洋', 'B001')] },
{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] },
{ id: 103, categoryId: 7, teamId: 3, squad: '二队' },
{ id: 104, categoryId: 8, teamId: 3, squad: '二队' },
]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [
// 单位 1:新报的名(entries 里还没有这一条)——导入那一步刚写进去,这里不该猜 id。
// 单位 2:原来挂着队员,这次一个都没勾 → 要发空数组把关联清掉。
// 单位 3:队伍名对上才算这一条;同单位同队名的另组别那条不算。
{ entryId: 101, athletes: [] },
{ entryId: 103, athletes: [{ athleteId: 31 }] },
])
})
test('planTeamAthleteSync:没有队员要写、原来也没有关联时一个请求都不发', () => {
const selections = [{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] }]
const entries = [{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] }]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [])
// 一个单位都没勾(或组别没选)时也没有要同步的东西。
assert.deepEqual(planTeamAthleteSync([], entries, 7), [])
assert.deepEqual(planTeamAthleteSync(selections, entries, undefined), [])
})
// ── 改队员:PUT /api/entries/{id}/athletes ──────────────────────────────────
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: { athletes?: unknown; requestId?: unknown }
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function withCsrfCookie<T>(run: () => Promise<T>): Promise<T> {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
return run().finally(() => {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
})
}
test('改队员走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键,body 只认 athleteId', async () => {
await withCsrfCookie(async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 7, athletes: [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')] },
}),
)
const outcome = await submitEntryAthletes(7, [{ athleteId: 12 }, { athleteId: 11 }])
assert.equal(entryAthletesPath(7), '/api/entries/7/athletes')
assert.equal(lastRequest.url, '/api/entries/7/athletes')
assert.equal(lastRequest.method, 'PUT')
assert.equal(lastRequest.credentials, 'include')
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
const headerId = lastRequest.headers?.['X-Request-Id']
assert.equal(typeof headerId, 'string', '写请求必须带幂等键(header)')
assert.equal(lastRequest.body?.requestId, headerId, 'header 与 body.requestId 是同一把钥匙')
assert.deepEqual(lastRequest.body?.athletes, [{ athleteId: 12 }, { athleteId: 11 }], '只认 athleteId,姓名 / 号码布不上行')
// 服务端回显的队员交给页面直接回填。
assert.deepEqual(outcome.athletes, [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')])
assert.equal(outcome.entry?.id, 7)
})
})
test('清空队员:空数组是合法载荷(=只报单位),不是「什么都没做」', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, athletes: [] } }))
const outcome = await submitEntryAthletes(7, [])
assert.equal(lastRequest.method, 'PUT')
assert.deepEqual(lastRequest.body?.athletes, [])
assert.deepEqual(outcome.athletes, [])
})
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
respondWith(() =>
jsonResponse(
{
ok: false,
error: {
code: 'VALIDATION_FAILED',
message: '这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名',
},
},
400,
),
)
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /不能再挂队员/)
return true
},
)
})
test('网络不可达时说清「没有改成功」,不能让操作员以为已经落库', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有改成功/)
return true
},
)
})
// ── 编辑器模型 ──────────────────────────────────────────────────────────────
function draftRow(athleteId: number, name: string, bib: string | null = null): AthleteDraftRow {
return { athleteId, name, bib }
}
function athlete(id: number, name: string, over: Partial<Athlete> = {}): Athlete {
return {
id,
name,
gender: null,
teamId: 1,
employeeNo: null,
idCard: null,
bib: null,
age: null,
active: true,
...over,
}
}
test('编辑器初值:摆出这条报名当前的队员;没动过就不发请求', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
const draft = athleteDraftOf({ athletes })
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.equal(athleteDraftDirty(draft, athleteDraftOf({ athletes })), false)
// 老数据(没有队员关联):打开就是空名单,一个勾都没有,等于没动过。
assert.deepEqual(athleteDraftOf({ athletes: [] }), [])
assert.deepEqual(athleteDraftOf({ athletes: null }), [])
assert.deepEqual(athleteDraftOf({}), [])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
})
test('加人 / 去人:顺序按点选先后,改回原样就不算改动', () => {
const original = athleteDraftOf({ athletes: [BIB(11, '张伟', 'A001')] })
let draft = addAthleteDraft(original, athlete(12, '李强', { bib: 'A002' }))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', 'A002')])
assert.equal(athleteDraftDirty(draft, original), true)
// 同一个人加不进两次。
assert.deepEqual(addAthleteDraft(draft, athlete(12, '李强')), draft)
draft = removeAthleteDraft(draft, 12)
assert.equal(athleteDraftDirty(draft, original), false, '去掉刚加的人=回到初值')
assert.deepEqual(removeAthleteDraft(draft, 99), draft, '去掉不在名单里的人不动草稿')
})
test('勾选式增删:草稿的顺序就是「队里有谁」的排列,写进去的 sort_order 跟着它', () => {
let draft = addAthleteDraft([], athlete(11, '张伟', { bib: 'A001' }))
draft = addAthleteDraft(draft, athlete(12, '李强'))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
draft = removeAthleteDraft(draft, 11)
assert.deepEqual(draft, [draftRow(12, '李强', null)])
})
test('候选名单:本单位启用中的人才可选,名单里已有的(停用 / 借调 / 档案没了)也看得见', () => {
const draft = [draftRow(11, '张伟', 'A001'), draftRow(13, '王芳', null), draftRow(31, '临时借调', null)]
const rows = athleteChoiceRows(
[
athlete(11, '张伟', { bib: 'A001', employeeNo: 'E11' }),
athlete(12, '李强', { active: false }),
athlete(13, '王芳', { active: false }),
athlete(21, '刘洋', { teamId: 2, bib: 'B001' }),
athlete(14, '赵六', { teamId: 1, employeeNo: 'E14' }),
],
draft,
1,
)
assert.deepEqual(
rows.map((row) => `${row.name}${row.picked ? '✓' : ''}${row.note === '' ? '' : `·${row.note}`}`),
['张伟✓', '赵六', '王芳✓·已停用', '临时借调✓·档案里查不到'],
'本单位启用中的人在前,已经在名单里的仍然看得见(借调来的人还在队里)',
)
assert.equal(rows.find((row) => row.name === '张伟')?.employeeNo, 'E11')
assert.equal(rows.some((row) => row.name === '李强'), false, '停用的人不能再被勾进名单')
assert.equal(rows.some((row) => row.name === '刘洋'), false, '别的单位的人不进候选(除非已经在这条报名上)')
// 借调来的人(别的单位、仍然启用)在名单里要标明「其他单位」,不能谎报成已停用。
const borrowed = athleteChoiceRows([athlete(21, '刘洋', { teamId: 2 })], [draftRow(21, '刘洋', 'B001')], 1)
assert.deepEqual(borrowed.map((row) => `${row.name}·${row.note}`), ['刘洋·其他单位'])
})
test('校验:没有队员时合法(=只报单位);超过上限要说出来', () => {
assert.deepEqual(athleteDraftIssues([]), [])
const many = Array.from({ length: MAX_ENTRY_ATHLETES + 1 }, (_, index) =>
draftRow(index + 1, `选手${index + 1}`),
)
assert.match(athleteDraftIssues(many).join(';'), /最多 30 人/)
assert.deepEqual(athleteDraftIssues(many.slice(0, MAX_ENTRY_ATHLETES)), [])
})
test('entryAthletesEditable:只有纯团体报名(没有运动员的)能改队员', () => {
assert.equal(entryAthletesEditable({ athleteId: null }), true)
assert.equal(entryAthletesEditable({ athleteId: 11 }), false, '个人报名的身份就是那一个运动员')
})
test('批量确认框:有队员的团体行念全名单,没有队员关联的行一个字都没变', () => {
const base = { bib: null, team: '第一分公司', categoryId: 7, entryStatus: 'registered' as const }
const plan = planEntryBatch('remove', [
{ ...base, entryId: 1, name: '张伟(A001)', members: '张伟(A001)、李强(A002)' },
{ ...base, entryId: 2, name: '王芳' },
])
assert.equal(plan.changed, 2)
assert.ok(
plan.details.some((line) => line.includes('张伟(A001)、李强(A002)|号码 —|第一分公司')),
'队里的每个人都写在确认框里(删掉的是这一整条报名)',
)
assert.ok(
plan.details.some((line) => line.includes('· 王芳|号码 —|第一分公司')),
'没有队员关联的行与升级前逐字一致',
)
})
// ── 接线(SFC 挂不进 node --test,用源码契约钉住,与 entry-squad.test.ts 同一套写法) ──
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
const RECORDS = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
const HISTORY = readFileSync(new URL('../pages/HistoryPage.vue', import.meta.url), 'utf8')
test('补报名接线:导入成功后再挂队员,失败不冒充「加入报名失败」', () => {
assert.match(
PICKER,
/await importSelection\(rows, event\.id\)[\s\S]{0,160}await syncTeamAthletes\(teams, category\.id\)/,
'先有报名才谈得上挂队员(athleteId 要挂到刚写出来的那条报名上)',
)
assert.match(PICKER, /planTeamAthleteSync\(selections, entries\.value, categoryId\)/)
assert.match(PICKER, /submitEntryAthletes\(item\.entryId, item\.athletes\)/)
// syncTeamAthletes 自己吞掉失败(报名已经写进去了),只单独提示队员没挂上。
assert.match(PICKER, /报名本身已经加入/)
// 示例数据模式没有真服务端:写请求发出去只会报一个假失败。
assert.match(PICKER, /if \(data\.isMock\.value\) return/)
})
test('录入页接线:只有纯团体行有「队员」入口,保存走 PUT 且不整表重拉', () => {
assert.match(ENTRY, /v-if="entryAthletesEditable\(row\)"/, '个人报名那一格不出现这个入口')
assert.match(ENTRY, /@click="openAthleteEditor\(row\)"/)
assert.match(ENTRY, /athletes: normalizeEntryAthletes\(entry\.athletes\)/, '老服务端不下发时按空数组')
assert.match(ENTRY, /const outcome = await submitEntryAthletes\(editor\.entryId, payload\)/)
// 没改动 / 有校验问题时不发请求(与成员单位弹窗同一口径)。
assert.match(ENTRY, /athleteEditorDirty\.value &&\s*athleteEditorIssues\.value\.length === 0/)
// 队员只决定分记给谁,不影响任何一行的成绩:保存后不整表重拉(会把没保存的成绩编辑冲掉)。
assert.match(ENTRY, /const saved = outcome\.athletes \?\? editor\.draft[\s\S]{0,400}void data\.refreshStandings\(\)/)
assert.doesNotMatch(ENTRY, /await submitEntryAthletes\(editor\.entryId, payload\)[\s\S]{0,400}loadEvent\(/)
})
test('显示接线:复核页 / 破纪录页 / 历史页的报名行都念队员(有队员看号码布,没队员不动)', () => {
assert.match(REVIEW, /\{\{ athleteCellName\(row\) \}\}/, '复核页运动员列走同一份口径')
assert.match(REVIEW, /row\.athleteName \|\| entryRosterText\(row\) \|\| team/, '报错文案里的行名也念队员')
assert.match(REVIEW, /athletes: normalizeEntryAthletes\(entry\.athletes\)/)
assert.match(RECORDS, /entry\.athleteName \?\? entryRosterText\(entry\) \?\? '—'/)
assert.match(HISTORY, /info\?\.athleteName \?\? entryRosterText\(info \?\? \{\}\) \?\? ''/)
})
+26 -6
View File
@@ -245,9 +245,18 @@ test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取
state = toggleMemberPick(state, 1, 11, true) state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true) state = toggleMemberPick(state, 3, 99, true)
// 勾中的人同时带上队员关联(athleteId,devdocs/20):姓名文字照旧进 memberNames。
assert.deepEqual(buildTeamSelections(state, teams, athletes), [ assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] }, {
{ teamCode: 'F2', memberNames: [] }, teamId: 1,
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [
{ athleteId: 11, name: '张伟', bib: null },
{ athleteId: 12, name: '李强', bib: null },
],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
]) ])
assert.deepEqual( assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes), buildTeamSelections(setPickedTeams(state, []), teams, athletes),
@@ -382,14 +391,25 @@ test('buildTeamSelections:填了队名的单位带上 squad,没填的与改
state = toggleTeamPick(state, 2, true) state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true) state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [ assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'] }, {
{ teamCode: 'F2', memberNames: [] }, teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
]) ])
const withSquad = setTeamPickSquad(state, 1, ' 二队 ') const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [ assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' }, {
{ teamCode: 'F2', memberNames: [] }, teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
squad: '二队',
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
]) ])
}) })
+3 -1
View File
@@ -55,7 +55,9 @@ test('复核页:单位列同样显示「单位名 + 队名」', () => {
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/) assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/) assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/) assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名') // 行名的链还是「文字名单 → 队名 → #id」,只是文字名单换成了 entryRosterText(有队员关联时
// 念「姓名(号码布)」、没有关联时就是 memberNames 的拼法,逐字不变,见 entry-athletes.test.ts)。
assert.match(REVIEW, /entryRosterText\(row\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
}) })
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => { test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
+10 -2
View File
@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { medalBoardRows } from '@/shared' import { medalBoardRows } from '@/shared'
import type { EventResultRow } from '@/shared' import type { EventResultRow } from '@/shared'
import { tickerRowText } from '../jointTeams' import { tickerRowText } from '../jointTeams'
import { rosterSuffix } from '../members'
import { displayResult } from '../results' import { displayResult } from '../results'
import { useScoreboard } from '../useScoreboard' import { useScoreboard } from '../useScoreboard'
import { DEFAULT_ROTATE_SECONDS } from '../guards' import { DEFAULT_ROTATE_SECONDS } from '../guards'
@@ -14,7 +15,7 @@ interface TickerItem {
label: string label: string
} }
const { standings, latestResults, announceText, detail, settings, jointTeamOf, squadOf } = const { standings, latestResults, announceText, detail, settings, jointTeamOf, squadOf, rosterOf } =
useScoreboard() useScoreboard()
/** /**
@@ -22,9 +23,16 @@ const { standings, latestResults, announceText, detail, settings, jointTeamOf, s
* *
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为 * 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
* 这条成绩是它一家拿的(成员单位与队名都来自报名表,见 display/jointTeams.ts)。 * 这条成绩是它一家拿的(成员单位与队名都来自报名表,见 display/jointTeams.ts)。
*
* 团体报名挂了队员时(devdocs/20)名单接在单位名后面(`第一分公司 · 张伟 / 赵六`):
* 底栏这一行本来就是「谁拿了这一名」,队员是这句话里最该看得出来的部分;单位名不许被顶掉
* ——联队 / 队名同样要认得出。没挂队员的报名拿到空后缀,与升级前逐字一致。
*/ */
function chipName(row: EventResultRow): string { function chipName(row: EventResultRow): string {
return tickerRowText(row, jointTeamOf(row.entryId), squadOf(row.entryId)) return (
tickerRowText(row, jointTeamOf(row.entryId), squadOf(row.entryId)) +
rosterSuffix(rosterOf(row.entryId))
)
} }
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。 // 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
@@ -7,7 +7,7 @@ import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue' import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue' import TeamLogo from '../TeamLogo.vue'
const { latestResults, options, jointTeamOf, squadOf } = useScoreboard() const { latestResults, options, jointTeamOf, squadOf, rosterOf } = useScoreboard()
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。 // 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const columns = computed(() => const columns = computed(() =>
@@ -26,6 +26,19 @@ function teamText(row: EventResultRow): string {
function teamLogos(row: EventResultRow): (string | null)[] { function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId)) return rowTeamLogos(row, jointTeamOf(row.entryId))
} }
/**
* 运动员列(devdocs/20):团体报名挂了队员就写队员名单(见 display/members.ts),
* 没挂队员的报名拿到 null,与改造前逐字一致。
*/
function athleteText(row: EventResultRow): string {
return displayName(row, rosterOf(row.entryId))
}
/** 带队员名单的行:运动员列放开折行(`.line.roster .nm`),名字长高而不是截成省略号。 */
function hasRoster(row: EventResultRow): boolean {
return rosterOf(row.entryId) !== null
}
</script> </script>
<template> <template>
@@ -41,10 +54,10 @@ function teamLogos(row: EventResultRow): (string | null)[] {
v-for="row in column.rows" v-for="row in column.rows"
:key="row.entryId" :key="row.entryId"
class="line" class="line"
:class="`top-${Math.min(row.rank ?? 99, 4)}`" :class="[`top-${Math.min(row.rank ?? 99, 4)}`, { roster: hasRoster(row) }]"
> >
<span class="rk">{{ row.rank ?? '—' }}</span> <span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span> <span class="nm">{{ athleteText(row) }}</span>
<!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 --> <!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 -->
<span class="team"> <span class="team">
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" /> <TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
@@ -128,6 +141,15 @@ h3 {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* 团体报名的队员名单(devdocs/20):运动员列在这一页只有 110~158px(28px 字号下约 4~5.6 个汉字),
一对双打的名单(`张伟 / 赵六`)塞不进一行。这一列是「谁上了场」,截成省略号就等于没写,
而这一行下面的高度是够的(6 行 × 45px 的余量),所以带队员的行放开折行:名字长高,
不截成省略号。列宽一分不动,普通报名(没有这个类)的行观感与升级前完全一致。 */
.line.roster .nm {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.15;
}
.team { .team {
display: flex; display: flex;
align-items: center; align-items: center;
+18 -1
View File
@@ -26,6 +26,7 @@ const {
refreshLiveEvents, refreshLiveEvents,
jointTeamOf, jointTeamOf,
squadOf, squadOf,
rosterOf,
} = useScoreboard() } = useScoreboard()
/** /**
@@ -41,6 +42,14 @@ function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId)) return rowTeamLogos(row, jointTeamOf(row.entryId))
} }
/**
* 运动员列(devdocs/20):团体报名挂了队员就写队员名单,没挂队员的报名拿到 null,
* 与改造前逐字一致(见 display/members.ts)。
*/
function athleteText(row: EventResultRow): string {
return displayName(row, rosterOf(row.entryId))
}
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name]))) const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
/** /**
@@ -96,6 +105,13 @@ const resultTexts = computed(() =>
const teamTexts = computed(() => const teamTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))), boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
) )
// 团体报名的队员名单(devdocs/20)同样要按实际写出来的字算:名字列原来只保证 4 个汉字,
// 一对双打的名单比它长,不传进预算就会被省略号截掉(见 fitFont.fitLiveRowFontPx 的 rosters)。
const rosterTexts = computed(() =>
boards.value.flatMap((board) =>
board.rows.filter((row) => rosterOf(row.entryId) !== null).map((row) => athleteText(row)),
),
)
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。 // 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
const teamsWithLogo = computed(() => const teamsWithLogo = computed(() =>
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')), boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
@@ -119,6 +135,7 @@ const rowFontPx = computed(() =>
teamTexts.value, teamTexts.value,
teamsWithLogo.value, teamsWithLogo.value,
maxLogosPerRow.value, maxLogosPerRow.value,
rosterTexts.value,
), ),
) )
@@ -179,7 +196,7 @@ onUnmounted(() => {
:class="`top-${Math.min(row.rank ?? 99, 4)}`" :class="`top-${Math.min(row.rank ?? 99, 4)}`"
> >
<span class="rk">{{ row.rank ?? '—' }}</span> <span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span> <span class="nm">{{ athleteText(row) }}</span>
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 --> <!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
<span class="team"> <span class="team">
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" /> <TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
+32 -6
View File
@@ -207,6 +207,22 @@ const MAX_TEAM_LOGOS = 8
/** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */ /** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */
const NAME_MIN_EM = 4 const NAME_MIN_EM = 4
/**
* 运动员列**必须装下**的宽度(em)。
*
* 默认就是 4 个汉字(与改动前一致);实时赛况页把「这一屏真的会写进运动员列的队员名单」
* 传进来时按实际内容算——团体报名的队员名单(`张伟 / 赵六`)比 4 个汉字长,
* 不把它算进预算就会被省略号截成三个点,而队员正是这一行要认的人。
*
* 与单位名同一套取舍(见 fitLiveRowFontPx):名字长的代价是整屏字号小一点,比截成省略号强;
* 字号有 16px 的下限,再长的名单到头来还是截断——那时是物理上排不下,不是没算。
*/
function nameBudgetEm(rosters: readonly string[]): number {
let widest = 0
for (const text of rosters) widest = Math.max(widest, textEm(text))
return Math.max(NAME_MIN_EM, widest)
}
/** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */ /** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */
function logoWidthEm(withLogo: boolean, logoCount = 1): number { function logoWidthEm(withLogo: boolean, logoCount = 1): number {
if (!withLogo) return 0 if (!withLogo) return 0
@@ -265,7 +281,7 @@ export function liveNameColumnPx(
return free - Math.max(team, share) return free - Math.max(team, share)
} }
/** 当前字号下这一行装得下吗:三列定宽 + 名字列至少 4 个汉字。 */ /** 当前字号下这一行装得下吗:三列定宽 + 名字列至少放得下这一屏的名单。 */
function liveRowFitsPx( function liveRowFitsPx(
results: readonly string[], results: readonly string[],
teams: readonly string[], teams: readonly string[],
@@ -273,9 +289,10 @@ function liveRowFitsPx(
fontPx: number, fontPx: number,
withLogo: boolean, withLogo: boolean,
logoCount: number, logoCount: number,
rosters: readonly string[],
): boolean { ): boolean {
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount) const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount)
return name >= NAME_MIN_EM * fontPx return name >= nameBudgetEm(rosters) * fontPx
} }
/** /**
@@ -287,9 +304,14 @@ function liveRowFitsPx(
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。 * 只有 `64:21.50` 这类特别长的成绩才真的收字号。
* *
* 真机缺陷二:单位列原来写死(两栏 190px / 拆两块 150px),长单位名一律省略号。单位名与成绩 * 真机缺陷二:单位列原来写死(两栏 190px / 拆两块 150px),长单位名一律省略号。单位名与成绩
* 抢的是同一行的宽度,所以一条行内网格的账要一起算:名次 + 名字(≥4 个汉字)+ 单位 + 成绩 * 抢的是同一行的宽度,所以一条行内网格的账要一起算:名次 + 名字 + 单位 + 成绩装得进一行,
* 装得进一行,装不下就往下收字号,直到名字列守不住 4 个汉字为止(那时由单位名省略号收场, * 装不下就往下收字号,直到名字列守不住为止(那时由单位名省略号收场,成绩与名次仍然完整)。
* 成绩与名次仍然完整)。单位名长的代价是整屏字号略小,比把单位名截成三个点强。 * 单位名长的代价是整屏字号略小,比把单位名截成三个点强。
*
* 真机缺陷三(devdocs/20):团体报名的队员名单比 4 个汉字长(`张伟 / 赵六` 这类),
* 而名字列的预算原来只保证 4 个汉字——挂上队员之后,一对双打的名字会被省略号截掉。
* `rosters` 是这一屏会写进名字列的名单(**没有队员的屏传空数组**,判据与改动前逐字相同),
* 名单也算进这条账:名字写全的代价是收几个 px 的字号,比把队员截掉强。
*/ */
export function fitLiveRowFontPx( export function fitLiveRowFontPx(
rowCount: number, rowCount: number,
@@ -298,6 +320,7 @@ export function fitLiveRowFontPx(
teams: readonly string[] = [], teams: readonly string[] = [],
teamsWithLogo = false, teamsWithLogo = false,
logoCount = 1, logoCount = 1,
rosters: readonly string[] = [],
): number { ): number {
const spec = LIVE_LAYOUT_SPECS[layout] const spec = LIVE_LAYOUT_SPECS[layout]
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx) const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
@@ -306,7 +329,10 @@ export function fitLiveRowFontPx(
if (widest > 0) { if (widest > 0) {
font = Math.min(font, Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest)) font = Math.min(font, Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest))
} }
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount)) { while (
font > MIN_ROW_FONT_PX &&
!liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount, rosters)
) {
font-- font--
} }
return Math.max(MIN_ROW_FONT_PX, font) return Math.max(MIN_ROW_FONT_PX, font)
+129
View File
@@ -0,0 +1,129 @@
/**
* 大屏侧的「团体报名的队员」(devdocs/20)。
*
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有主单位与 `memberNames`(文字名单),
* 而**队员关联**只存在于报名表(`GET /api/events/{id}/entries` 的 `athletes`)。这一份读数
* 大屏本来就在为联队成员与队名读一次(见 display/jointTeams.ts 与 useScoreboard 的
* loadEventShares),这里把它的 athletes 收敛成 `entryId → 这一行要写的名单`,再抽成纯函数,
* 页面只负责把结果画出来。
*
* 两条硬口径:
* 1. 没有队员关联的报名(老数据、只登记文字名单的报名)**不进索引**:调用方拿到的 null
* 就是「按原来那样渲染」,于是个人姓名 / memberNames / 单位名三条老路一个字都没动;
* 2. member_names 是**显示用的文字名单**,不因为挂了队员就丢掉:里面没被队员认走的那些
* 名字(人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。
*
* 只写姓名、不写号码布:号码布是**对账**用的唯一标识,写在导出件与录入页上(「姓名(号码布)」);
* 大屏的运动员列在分项成绩卡里只有约 110~158px、拆两块版式下约 240px,一个「姓名(号码布)」
* 标签本身就有约 7em,而一对双打是约 14em——写上去只能把整屏字号收到 20px 上下,
* 那是把大屏读废了(见 fitFont 的队员预算:写姓名只收几个 px,写号码布要砍掉一半)。
*/
/** 大屏侧的一名队员:athleteId 是身份,姓名与号码布是展示字段。 */
export interface DisplayEntryMember {
athleteId: number
/** 队员姓名;空串表示档案里没填名字,此时这一名队员不写进名单。 */
name: string
/** 号码布(运动员的唯一标识);空串表示档案里没填。 */
bib: string
}
/** 名单在运动员列 / 底栏里的分隔符:与 memberNames 原来的写法同一个字。 */
export const ROSTER_SEPARATOR = ' / '
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* 解析一条报名的 `athletes`。缺字段、类型不对、athleteId 非法一律当「没有这一名队员」,
* 不抛异常——老服务端根本没下发这个字段(读侧是新契约),大屏不能因此整页渲染不出来。
*
* athleteId 是身份(写侧只认它):缺了就不是一条能用的关联,宁可少写一个人也不能凭空造身份。
*/
export function readEntryAthletes(source: unknown): DisplayEntryMember[] {
if (typeof source !== 'object' || source === null) return []
const raw = (source as { athletes?: unknown }).athletes
if (!Array.isArray(raw)) return []
const out: DisplayEntryMember[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue
const record = item as { athleteId?: unknown; name?: unknown; bib?: unknown }
const athleteId = record.athleteId
if (typeof athleteId !== 'number' || !Number.isInteger(athleteId) || athleteId <= 0) continue
out.push({ athleteId, name: text(record.name), bib: text(record.bib) })
}
return out
}
/** 一条报名的文字名单(`entries.memberNames`);缺字段或类型不对时是空数组。 */
export function readMemberNames(source: unknown): string[] {
if (typeof source !== 'object' || source === null) return []
const raw = (source as { memberNames?: unknown }).memberNames
if (!Array.isArray(raw)) return []
return raw.filter((item): item is string => typeof item === 'string')
}
/**
* 一条报名要写在屏幕上的名单:先按报名表顺序写**队员**,再把文字名单里没被队员认走的
* 名字接在后面。
*
* 「认走」的判据与迁移 0026 的回填、导出侧的 participantLabel 同源(那份在 Go 里):
* 文字名单里写的是**号码布**(导入规范允许这么写)或**姓名**时,都算这个人已经在队员里了,
* 不再重复写一遍。判据不一致会出现「同一个人在屏上出现两次」或「写过的人反而漏掉」。
*/
export function rosterLabels(
members: readonly DisplayEntryMember[],
names: readonly string[] = [],
): string[] {
const out: string[] = []
for (const member of members) {
if (member.name !== '') out.push(member.name)
}
const seen = new Set(out)
for (const name of names) {
const value = name.trim()
if (value === '' || seen.has(value)) continue
if (members.some((member) => value === member.bib || value === member.name)) continue
seen.add(value)
out.push(value)
}
return out
}
/**
* 一批报名 → `entryId → 名单`,**只收有队员关联的报名**。
*
* 没进表的报名,调用方拿到的 null 就是「按原来那样渲染」:普通报名的成绩行与大屏改造前
* 完全一致(连一个多余的节点都不多)。名单为空(队员都没填姓名、文字名单也没有)时同样不进表,
* 免得屏上出现一个空格子。
*/
export function entryRosterIndex(entries: readonly unknown[]): Map<number, string[]> {
const out = new Map<number, string[]>()
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) continue
const id = (entry as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) continue
const members = readEntryAthletes(entry)
if (members.length === 0) continue
const roster = rosterLabels(members, readMemberNames(entry))
if (roster.length > 0) out.set(id, roster)
}
return out
}
/** 名单的显示文案:`王五 / 赵六`。 */
export function rosterText(roster: readonly string[]): string {
return roster.join(ROSTER_SEPARATOR)
}
/**
* 底栏一行的后缀:` · 王五 / 赵六`;没有队员时是空串(一个字都不加)。
*
* 底栏只有一行字,写不下一个 logo 也写不下两行,但队员必须看得出来——写不下的是队徽,
* 不是人。单位名(含联队成员与队名)照旧在前面,队员接在后面,两者互不覆盖。
*/
export function rosterSuffix(roster: readonly string[] | null | undefined): string {
if (roster === null || roster === undefined || roster.length === 0) return ''
return ' · ' + rosterText(roster)
}
+15 -3
View File
@@ -1,5 +1,6 @@
import { formatResultText } from '@/shared' import { formatResultText } from '@/shared'
import type { EventResultGroup, EventResultRow, ResultStatus } from '@/shared' import type { EventResultGroup, EventResultRow, ResultStatus } from '@/shared'
import { ROSTER_SEPARATOR } from './members'
/** /**
* 成绩行的展示口径:分项成绩页、实时赛况页、底栏轮播与积分明细覆盖层共用, * 成绩行的展示口径:分项成绩页、实时赛况页、底栏轮播与积分明细覆盖层共用,
@@ -42,10 +43,21 @@ export function displayResult(row: EventResultRow): string {
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none') return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
} }
/** 运动员列:个人项目用姓名,团体项目用成员名单,都缺时退回单位简称。 */ /**
export function displayName(row: EventResultRow): string { * 运动员列:个人项目用姓名,团体报名用**队员名单**(devdocs/20:挂上队员之后写队员,
* 没挂队员就写 memberNames 那份文字名单),都缺时退回单位简称。
*
* `roster` 是 `entryId → 名单` 里这一条报名的名单(见 display/members.ts),
* **没有队员关联的报名拿到的是 null**,三条老路(姓名 / 文字名单 / 单位名)与升级前逐字一致。
* 名单只写姓名、不写号码布,理由见 members.ts 的文件头(大屏的运动员列装不下带号码布的名字)。
*/
export function displayName(
row: EventResultRow,
roster: readonly string[] | null = null,
): string {
if (roster !== null && roster.length > 0) return roster.join(ROSTER_SEPARATOR)
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
if (row.memberNames !== null) return row.memberNames.join(' / ') if (row.memberNames !== null) return row.memberNames.join(ROSTER_SEPARATOR)
return row.shortName ?? row.teamName return row.shortName ?? row.teamName
} }
+255
View File
@@ -0,0 +1,255 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse } from 'vue/compiler-sfc'
import type { EventResultRow } from '@/shared'
import {
entryRosterIndex,
readEntryAthletes,
rosterSuffix,
rosterText,
} from '../members'
import { displayName } from '../results'
import { fitLiveRowFontPx, liveNameColumnPx, liveRowGeometry, liveTeamColumnPx, textEm } from '../fitFont'
/**
* 大屏侧的「团体报名的队员」(devdocs/20)。
*
* 大屏拿到的成绩行里只有主单位与 memberNames,**队员关联**只存在于报名表
* (`GET /api/events/{id}/entries` 的 athletes),而这一份读数大屏本来就在为联队 / 队名读
* (见 display/jointTeams.ts 与 useScoreboard 的 loadEventShares)。这里把它的 athletes 收敛成
* `entryId → 队员名单`,并抽成纯函数,页面只负责把结果画出来。
*
* 两件事必须同时成立:
* 1. **没有队员关联的报名(老数据)逐字不变**:索引里根本没有这条报名的位置,调用方拿到的
* null 就是「按原来那样渲染」,于是个人姓名 / memberNames / 单位名三条老路一个字都没动;
* 2. 挂了队员之后,即使 member_names 是空的(界面勾人时没写文字名单)也要看得出队里有谁——
* 这正是本单在大屏上要交付的东西。
*
* 号码布只写进导出件(`姓名(号码布)`),大屏只写姓名:大屏的运动员列在分项成绩卡里只有
* 约 110~158px、「拆两块」版式下约 240px,一个「姓名(号码布)」标签本身就有约 7em,
* 而一对双打的名单是约 14em——写上去只能靠把整屏字号收到 20px 上下,那是把大屏读废了。
* 见 fitFont 的队员预算用例:姓名写全的代价是收字号,号码布要的是把字号砍半。
*/
function row(over: Partial<EventResultRow> = {}): EventResultRow {
return {
entryId: 1,
rank: 1,
teamId: 1,
teamCode: 'T1',
teamName: '第一分公司',
shortName: '一司',
logoPath: null,
athleteId: null,
athleteName: null,
memberNames: null,
resultText: '21',
resultValue: 21,
unit: 'pts',
isTie: false,
status: 'ok',
...over,
}
}
/** 报名表里的一条报名:`athletes` 就是读侧下发的队员关联。 */
function entry(id: number, over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id,
teamId: 1,
teamCode: 'T1',
teamName: '第一分公司',
memberNames: null,
squad: '',
...over,
}
}
test('athletes 解析:缺字段 / 类型不对一律当「没有队员关联」,不抛异常', () => {
assert.deepEqual(readEntryAthletes({}), [])
assert.deepEqual(readEntryAthletes({ athletes: null }), [])
assert.deepEqual(readEntryAthletes({ athletes: '王五' }), [])
assert.deepEqual(readEntryAthletes(null), [])
// 老服务端下发的 entry 连这个字段都没有:老数据一律走「没有队员」那条路。
assert.deepEqual(readEntryAthletes({ id: 1, athleteId: null }), [])
// athleteId 是身份(写侧只认它),缺了就不是一条能用的关联。
assert.deepEqual(readEntryAthletes({ athletes: [{ name: '王五', bib: 'A001' }] }), [])
assert.deepEqual(readEntryAthletes({ athletes: [{ athleteId: 3, name: '王五', bib: null }] }), [
{ athleteId: 3, name: '王五', bib: '' },
])
})
test('无队员关联的报名一个都不进索引(老数据走原来的渲染)', () => {
const index = entryRosterIndex([
entry(11, { memberNames: ['王磊', '赵雷'] }),
entry(12, { athletes: [] }),
entry(13, { athleteId: 5, athleteName: '张三' }),
entry(14, { athletes: [{ athleteId: 3, name: '王五', bib: 'A001' }] }),
])
assert.equal(index.has(11), false, 'member_names 是文字名单,不是队员关联')
assert.equal(index.has(12), false)
assert.equal(index.has(13), false, '个人报名自己就是那个人的身份,没有队员这回事')
assert.deepEqual(index.get(14), ['王五'])
})
test('队员名单按报名表顺序写出来;文字名单里没被认走的名字接在后面,两者并存', () => {
const index = entryRosterIndex([
entry(21, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: 'A002' },
],
// 「临时借调的老周」没录档案,认不出人:他照旧留在屏幕上的名单里,不能被队员覆盖掉。
memberNames: ['王五', '赵六', '临时借调的老周'],
}),
])
assert.deepEqual(index.get(21), ['王五', '赵六', '临时借调的老周'])
})
test('文字名单里写的是号码布时(导入规范允许)不会被重复写一遍', () => {
const index = entryRosterIndex([
entry(31, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: 'A002' },
],
memberNames: ['A001', 'A002'],
}),
])
assert.deepEqual(index.get(31), ['王五', '赵六'])
})
test('member_names 为空也写得出来(界面勾人时没写文字名单的那种报名)', () => {
const index = entryRosterIndex([
entry(41, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: '' },
],
}),
])
assert.deepEqual(index.get(41), ['王五', '赵六'], '号码布为空时只写姓名,不留空括号')
})
test('运动员列:有队员名单就写名单,没有就与升级前逐字一致', () => {
// 老数据的三条路,一个字都不许变。
assert.equal(displayName(row({ athleteName: '张三' })), '张三')
assert.equal(displayName(row({ memberNames: ['王磊', '赵雷'] })), '王磊 / 赵雷')
assert.equal(displayName(row({ memberNames: null })), '一司')
// 文字名单是空数组(老契约里出现过)时仍旧写空串:这是升级前就有的行为,本单一个字没动。
assert.equal(displayName(row({ memberNames: [], shortName: null })), '')
// 空数组(索引里没有这条报名)与大屏上的老路径同责:不许把单位名换成空白。
assert.equal(displayName(row({ memberNames: ['王磊'] }), []), '王磊')
assert.equal(displayName(row({ memberNames: null }), ['王五', '赵六']), '王五 / 赵六')
})
test('名单文案与底栏后缀:一个名字时不留分隔符', () => {
assert.equal(rosterText(['王五']), '王五')
assert.equal(rosterText(['王五', '赵六']), '王五 / 赵六')
assert.equal(rosterSuffix(['王五', '赵六']), ' · 王五 / 赵六')
assert.equal(rosterSuffix(null), '')
assert.equal(rosterSuffix([]), '')
})
// 现场量级:一对双打(「张伟 / 赵六」这类两个名字的名单)在三种版式下都要写得下。
const DOUBLES = ['张伟 / 赵六', '王五 / 赵六']
const LONG_ROSTER = ['第三保障支援分队一队 / 机关直属通信保障分队二队']
const RESULTS = ['1:06.33', '1:09.09']
const TEAMS = ['第一分公司']
const LAYOUTS = ['single', 'split', 'duo'] as const
test('队员名单进运动员列的宽度预算:名字不会被省略号截掉', () => {
for (const layout of LAYOUTS) {
const font = fitLiveRowFontPx(6, layout, RESULTS, TEAMS, false, 1, DOUBLES)
const name = liveNameColumnPx(RESULTS, TEAMS, layout, font)
// 运动员列的字号与行字号同为 1em(.nm 没有单独的 font-size),墨迹必须装得进列宽:
// 这就是「单元格不进入省略态」的判据(省略号只在 scrollWidth > clientWidth 时出现)。
assert.ok(
textEm('张伟 / 赵六') * font <= name,
`${layout}:${font}px 字号下队员名单要 ${textEm('张伟 / 赵六') * font}px,列宽只有 ${name}px`,
)
// 收字号不许把单位列挤到下限以下(那会让单位名反过来被截掉)。
assert.ok(liveTeamColumnPx(TEAMS, layout, font) >= liveRowGeometry(layout).teamFloorPx)
}
})
test('常见的双打(单位名不长)只让出几个 px 的字号,大屏不至于变小', () => {
for (const rows of [1, 3, 5, 6]) {
for (const layout of LAYOUTS) {
const withRoster = fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, false, 1, ['张伟 / 赵六'])
const without = fitLiveRowFontPx(rows, layout, RESULTS, TEAMS)
assert.ok(withRoster <= without, `${layout} ${rows} 行:队员名单只该让字号`)
assert.ok(without - withRoster <= 6, `${layout} ${rows} 行:让出了 ${without - withRoster}px`)
assert.ok(withRoster >= 44, `${layout} ${rows} 行:字号只剩 ${withRoster}px`)
}
}
})
test('不带队员名单时字号与改动前逐字一致(老数据零变化)', () => {
// 与 fitFont.test.ts 里那几张表同源:多出来的第七个参数不给时要完全等价。
const expected = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
for (let rows = 1; rows <= 10; rows++) {
assert.equal(fitLiveRowFontPx(rows, 'duo', ['1:06.33', '1:09.09', '1:10.55', '59.99']), expected[rows - 1])
assert.equal(
fitLiveRowFontPx(rows, 'duo', ['1:06.33', '1:09.09', '1:10.55', '59.99'], TEAMS, false, 1, []),
expected[rows - 1],
'空名单等于没有队员:判据与改动前一模一样',
)
}
for (const rows of [1, 3, 6]) {
for (const layout of LAYOUTS) {
assert.equal(
fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, true, 2, []),
fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, true, 2),
)
}
}
})
test('队员名单很长时只收字号(收到下限为止),不收成省略号', () => {
const font = fitLiveRowFontPx(6, 'single', RESULTS, TEAMS, false, 1, LONG_ROSTER)
assert.ok(font < fitLiveRowFontPx(6, 'single', RESULTS, TEAMS), '长名单要让出字号')
assert.ok(font >= 16, '字号不许低于下限(再小就 10 米外看不清)')
const name = liveNameColumnPx(RESULTS, TEAMS, 'single', font)
assert.ok(textEm(LONG_ROSTER[0]!) * font <= name, '收完字号之后名单仍要装得下')
})
// 页面契约:模板里必须真的把队员名单传下去(纯函数的用例证明不了「页面用了它」)。
function sourceOf(relative: string): string {
return readFileSync(new URL(relative, import.meta.url), 'utf8')
}
test('分项成绩页与实时赛况页把队员名单交给运动员列,并留出换行的余地', () => {
const results = sourceOf('../components/pages/EventResultsPage.vue')
assert.match(results, /displayName\(row, rosterOf\(row\.entryId\)\)/)
const live = sourceOf('../components/pages/LivePage.vue')
assert.match(live, /displayName\(row, rosterOf\(row\.entryId\)\)/)
// 实时赛况的字号预算要把名单一起算进去(第七个参数)。
assert.match(live, /fitLiveRowFontPx\([\s\S]*?rosterTexts\.value[\s\S]*?\)/)
assert.match(live, /const rosterTexts = computed\(/)
})
test('分项成绩卡的运动员列本来就窄:带队员的行允许折行,宁可长高也不截成省略号', () => {
const source = sourceOf('../components/pages/EventResultsPage.vue')
const { descriptor } = parse(source, { filename: 'EventResultsPage.vue' })
const style = descriptor.styles[0]?.content ?? ''
const rule = /\.line\.roster \.nm\s*\{([^}]*)\}/.exec(style)
assert.ok(rule !== null, '缺少 .line.roster .nm 规则')
assert.match(rule[1] ?? '', /white-space:\s*normal/, '要把 nowrap 放开才折得动')
assert.match(source, /roster: hasRoster\(row\)/, '带队员的行要挂上这个类')
})
test('底栏的队员跟着单位名一起写(联队 / 队名不许被队员顶掉)', () => {
const source = sourceOf('../components/TickerBar.vue')
assert.match(source, /rosterSuffix\(rosterOf\(row\.entryId\)\)/)
})
test('大屏为屏上的小项读一次报名表就把队员一起收下来(不多发请求)', () => {
const source = sourceOf('../useScoreboard.ts')
assert.match(source, /entryRosterIndex\(entries\)/)
assert.match(source, /function rosterOf\(entryId: number\)/)
// 重读时旧值要删掉(换人 / 清空队员之后屏上不能留着旧名单),与份额 / 队名同一套。
assert.match(source, /nextRosters\.delete\(id\)/)
})
+26 -1
View File
@@ -23,6 +23,7 @@ import {
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards' import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
import { entryShareIndex, entrySquadIndex } from './jointTeams' import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams' import type { DisplayTeamShare } from './jointTeams'
import { entryRosterIndex } from './members'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts' import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live' import { liveRefreshDelay } from './live'
@@ -276,6 +277,15 @@ const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
*/ */
const entrySquads = ref<Map<number, string>>(new Map()) const entrySquads = ref<Map<number, string>>(new Map())
/**
* `entryId → 队员名单`(只收挂了队员的报名,devdocs/20)。同样搭这次请求的顺风车:
* 队员关联与份额、队名都在 `GET /api/events/{id}/entries` 的同一份读数里。
*
* 取不到(老服务端 / 请求失败 / 这条报名没有队员)就是 null = 按原来那样渲染,
* 屏上逐字不变(见 display/members.ts)。
*/
const entryRosters = ref<Map<number, string[]>>(new Map())
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */ /** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
const sharesFetchedAt = new Map<number, number>() const sharesFetchedAt = new Map<number, number>()
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */ /** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
@@ -298,13 +308,16 @@ async function loadEventShares(eventId: number): Promise<void> {
const entries = await api.events.entries(eventId) const entries = await api.events.entries(eventId)
const index = entryShareIndex(entries, teams.value) const index = entryShareIndex(entries, teams.value)
const squads = entrySquadIndex(entries) const squads = entrySquadIndex(entries)
const rosters = entryRosterIndex(entries)
const nextShares = new Map(entryShares.value) const nextShares = new Map(entryShares.value)
const nextSquads = new Map(entrySquads.value) const nextSquads = new Map(entrySquads.value)
// 重读要先删掉上次读回来的那些报名:改成单单位 / 清掉队名后,旧值不能留在屏上。 const nextRosters = new Map(entryRosters.value)
// 重读要先删掉上次读回来的那些报名:改成单单位 / 清掉队名 / 换掉队员之后,旧值不能留在屏上。
const previousIds = shareEntryIds.get(eventId) ?? [] const previousIds = shareEntryIds.get(eventId) ?? []
for (const id of previousIds) { for (const id of previousIds) {
nextShares.delete(id) nextShares.delete(id)
nextSquads.delete(id) nextSquads.delete(id)
nextRosters.delete(id)
} }
const ids = new Set<number>() const ids = new Set<number>()
for (const [entryId, members] of index) { for (const [entryId, members] of index) {
@@ -316,9 +329,15 @@ async function loadEventShares(eventId: number): Promise<void> {
// 只有队名的报名同样要记住 id,下次重读才删得掉。 // 只有队名的报名同样要记住 id,下次重读才删得掉。
ids.add(entryId) ids.add(entryId)
} }
for (const [entryId, roster] of rosters) {
nextRosters.set(entryId, roster)
// 只挂了队员的报名同样要记住 id(份额与队名都没有时也删得掉)。
ids.add(entryId)
}
shareEntryIds.set(eventId, [...ids]) shareEntryIds.set(eventId, [...ids])
entryShares.value = nextShares entryShares.value = nextShares
entrySquads.value = nextSquads entrySquads.value = nextSquads
entryRosters.value = nextRosters
sharesFetchedAt.set(eventId, Date.now()) sharesFetchedAt.set(eventId, Date.now())
} catch { } catch {
// 静默:没有联队 / 队伍信息时屏上仍是主单位,与升级前一致。 // 静默:没有联队 / 队伍信息时屏上仍是主单位,与升级前一致。
@@ -356,6 +375,11 @@ function squadOf(entryId: number): string | null {
return entrySquads.value.get(entryId) ?? null return entrySquads.value.get(entryId) ?? null
} }
/** 某个报名项的队员名单;没挂队员 / 没读到都是 null,调用方退回原来的姓名 / 文字名单 / 单位名。 */
function rosterOf(entryId: number): string[] | null {
return entryRosters.value.get(entryId) ?? null
}
watch(sharesKey, () => { watch(sharesKey, () => {
void ensureEntryShares(sharesEventIds.value) void ensureEntryShares(sharesEventIds.value)
}) })
@@ -758,6 +782,7 @@ export function useScoreboard() {
teams, teams,
jointTeamOf, jointTeamOf,
squadOf, squadOf,
rosterOf,
liveFeed, liveFeed,
liveStale, liveStale,
page, page,
+103
View File
@@ -0,0 +1,103 @@
import type { EntryAthlete } from './types'
/**
* 团体报名的「队员」口径(devdocs/20),前端只有这一份实现。
*
* 队员是报名上的**人员关联**:一条纯团体报名挂 0..N 个运动员,0 个 = 只报单位。
* 界面要念成同一个样子——录入页的姓名列、复核页的报名行、批量确认框、报错行名都读这里,
* 各拼一套「姓名 + 号码布」迟早会变成一处带号码布、一处不带的局面。
*
* 与 `memberNames`(纯文字名单)**并存**:文字名单是没录档案的人唯一的登记方式,
* 老数据里也只有它。所以这里的规矩是——**一个队员都没有时,输出与升级前逐字一致**。
*/
/** 号码布去空白:库里可能存着手工填进去的空格,展示时不该带出来。 */
function bibText(bib: unknown): string {
return typeof bib === 'string' ? bib.trim() : ''
}
/**
* 读侧收口:接口给的队员补齐成数组,丢掉脏行、按 athleteId 去重、号码布去空白。
*
* 老服务端不下发这个字段、库里还没跑到 0026 的迁移,都走「空数组 = 没有队员」——
* 这正是老数据本来的样子,不是容错降级。
*
* 脏数据(athleteId 非法、同一个人出现两次)在这里丢掉:队员是「谁拿到个人分」的依据,
* 界面把同一个人列两遍、或者显示一个不存在的 id,比少显示一名队员更容易误导现场。
* 真身份由服务端(entry_athletes 主键)兜着,这里只是不让界面炸。
*/
export function normalizeEntryAthletes(value: unknown): EntryAthlete[] {
if (!Array.isArray(value)) return []
const seen = new Set<number>()
const out: EntryAthlete[] = []
for (const item of value) {
if (item === null || typeof item !== 'object') continue
const row = item as Partial<EntryAthlete>
const athleteId = row.athleteId
if (typeof athleteId !== 'number' || !Number.isInteger(athleteId) || athleteId <= 0) continue
if (seen.has(athleteId)) continue
seen.add(athleteId)
out.push({
athleteId,
name: typeof row.name === 'string' ? row.name : '',
bib: bibText(row.bib) === '' ? null : bibText(row.bib),
})
}
return out
}
/**
* 一名队员的展示文案:`张伟(A001)`。
*
* 带号码布是因为**号码布才是运动员的唯一标识**(姓名会重名,两个「张伟」在同一支队里
* 光看名字分不出谁是谁);人没录号码布时就只写姓名——不编一个假的号码布出来。
*/
export function entryAthleteText(athlete: EntryAthlete): string {
const bib = bibText(athlete.bib)
const name = typeof athlete.name === 'string' ? athlete.name : ''
return bib === '' ? name : `${name}(${bib})`
}
export interface EntryRosterLike {
athletes?: readonly EntryAthlete[] | null
memberNames?: readonly string[] | null
}
/**
* 报名行「队里都有谁」的文案。
*
* 有队员关联:`张伟(A001)、李强(A002)`,再把 memberNames 里**没有档案**的人接在后面
* (文字名单里有、队员关联里没有的名字=这个人只能留文字)。文字名单里的人已经在队员里
* 时不重复列一遍——勾选报名写出来的正是「两份都有同一批人」。
*
* 一个队员都没有(老数据 / 只报单位):**原样返回 memberNames 的拼法**,连「空数组拼出空串」
* 这个细节都一致,界面上一个字符都不会变。
*
* 两者都没有时返回 null(而不是空串),让调用方沿用自己原来的回落(占位符 / 单位名 / `#id`)。
*/
export function entryRosterText(row: EntryRosterLike): string | null {
const athletes = normalizeEntryAthletes(row.athletes)
if (athletes.length === 0) {
return row.memberNames ? row.memberNames.join('、') : null
}
const shown = athletes.map(entryAthleteText)
const covered = new Set(athletes.map((athlete) => athlete.name))
for (const name of row.memberNames ?? []) {
if (covered.has(name)) continue
shown.push(name)
}
return shown.join('、')
}
/**
* 报名行「人」的**首人**文案:一行只念一个人时用(报错行名、批量确认框的单行身份)。
*
* 有队员关联时是首名队员(`张伟(A001)`,号码布让重名的两个人分得开);
* 没有关联时就是 memberNames 的第一个人——与升级前的口径逐字一致(那时也只取首人)。
* 都没有时返回空串,由调用方决定回落什么。
*/
export function entryRosterHead(row: EntryRosterLike): string {
const athletes = normalizeEntryAthletes(row.athletes)
if (athletes.length > 0) return entryAthleteText(athletes[0] as EntryAthlete)
return row.memberNames?.[0] ?? ''
}
+7
View File
@@ -38,6 +38,13 @@ export {
teamSharePayload, teamSharePayload,
} from './jointTeams' } from './jointTeams'
export type { TeamShareView } from './jointTeams' export type { TeamShareView } from './jointTeams'
export {
entryAthleteText,
entryRosterHead,
entryRosterText,
normalizeEntryAthletes,
} from './entryAthletes'
export type { EntryRosterLike } from './entryAthletes'
export { normalizeSquad, teamSquadLabel } from './squad' export { normalizeSquad, teamSquadLabel } from './squad'
export { export {
breakdownAthleteName, breakdownAthleteName,
+28
View File
@@ -234,6 +234,25 @@ export interface EntryTeamShare {
teamName?: string teamName?: string
} }
/**
* 团体报名上的一名**队员**(devdocs/20):这条报名挂在谁名下的人。
*
* `athleteId` 是唯一身份——认人看**号码布**(`bib`),姓名会重名,所以姓名只是展示用的;
* 与联队那套(`EntryTeamShare`)对称而语义相反:那边是一条报名多个**单位**、按权重**拆**分,
* 这边是一条报名多个**运动员**、每人各拿**整份**分与各自的一枚奖牌。
*/
export interface EntryAthlete {
athleteId: number
name: string
/** 号码布:运动员的唯一标识,展示成「姓名(号码布)」;没有档案号码布时为 null。 */
bib: string | null
}
/** 写侧的一名队员:只认 athleteId(姓名 / 号码布是读侧的展示字段,服务端不二次猜人)。 */
export interface EntryAthleteInput {
athleteId: number
}
export interface Entry { export interface Entry {
id: number id: number
eventId: number eventId: number
@@ -267,6 +286,15 @@ export interface Entry {
* 联队是多条(外层 `teamId` 恒为主单位)。老服务端不下发这个字段,收口在 normalizeTeamShares。 * 联队是多条(外层 `teamId` 恒为主单位)。老服务端不下发这个字段,收口在 normalizeTeamShares。
*/ */
teamShares?: EntryTeamShare[] teamShares?: EntryTeamShare[]
/**
* 这条报名的**队员**(团队 / 对抗项目里坐着谁,devdocs/20):读侧恒为数组,
* 空数组 = 只报单位(升级前所有团体报名的样子)。老服务端不下发这个字段,
* 读侧一律收口到 normalizeEntryAthletes,界面按「没有队员」处理。
*
* 与 `memberNames` 是**两件事、可以并存**:队员指向人员档案(算个人分 / 个人奖牌靠它),
* memberNames 只是显示用的文字名单——没录档案的人(临时借调)只能留文字,老数据也只有它。
*/
athletes?: EntryAthlete[]
} }
export interface EntryWithResult extends Entry { export interface EntryWithResult extends Entry {