feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。
## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。
## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
`entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。
## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。
## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。
## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。
## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。
## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。
## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
7a3e4ad365
commit
99c91f99f7
57 files changed
+4903
-94
No files matched your search
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 @@
|
|||||||
0.5.5
|
0.5.6
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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,保证 (通道, 组别, 主体) 的唯一索引不会撞键。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 成绩;一条报名项一条成绩。
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 等价;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 可选、只进审计便于事后对账。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)。那一路一律写空串,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 || '未知单位'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 \?\? \{\}\) \?\? ''/)
|
||||||
|
})
|
||||||
@@ -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: [] },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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\)/)
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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] ?? ''
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user