feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3

## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
This commit is contained in:
AskaEth committed 2026-09-14 03:52:34 +08:00
1 parent b631418a85
commit 830a4dbdb7
61 files changed
+5649 -121

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.2
dev_0.5.3
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.2
0.5.3
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.2",
"version": "0.5.3",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+61
View File
@@ -102,6 +102,8 @@
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。
**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。
| 列 | 操作 |
| --- | --- |
| 成绩输入 | 直接输入成绩;工具栏会提示当前单位(秒 / 米 / 次 / 分),成绩格比过去更宽,四位数也看得全;打字时格子旁边会浮出一个小提示(见下) |
@@ -272,6 +274,45 @@
> 需要先「存草稿」再动这些入口:修改 / 作废 / 退回会重算并整表刷新,未保存的编辑会被刷新覆盖,页面会拦下并提示。
### 4.11 联队(两个单位合并参赛)
有些项目**两个单位因参赛人数不足,合并成一个单位参赛**。这种情况**不要新建一个「联队单位」**——那样榜单会多出一个单位,联队拿到的分与奖牌也分不到原单位头上。正确做法是**一条报名挂多个单位、按份额分成**:榜单仍然是原来的单位数,分数与奖牌按份额记给各单位,联队的名字在大屏与导出件上写全。
**三个口径(赛会已定,录之前先知道)**
| 口径 | 怎么写 |
| --- | --- |
| 份额 | **可配、默认均分**:两个单位各占一半(50% / 50%);要改成别的比例(例如 2:1)就填权重 |
| 积分 | **平均拆,接受 .5**:团体第 1 名 15 分 → 两个单位**各 7.5 分**(榜单上就写 7.5) |
| 奖牌 | **每个成员单位各记一枚整枚**:联队的金牌,两个单位**各记 1 枚**(不是各 0.5 枚) |
> ⚠️ 由此产生一个**必须知道**的结果:**各单位的奖牌数之和会大于实际颁发的奖牌数**。A、B 联队拿 1 枚金牌,奖牌榜上 A 记 1 金、B 记 1 金,而场上只颁发了 1 枚。**这是定稿口径,不是数据错了**;拿奖牌数去和实际颁发的奖牌核对时请记住这一点。导出件的「总表 / 单位奖牌明细」副标题里也写着这句话。
**怎么配(两个入口)**
1. **导入报名表时**:把「单位编码」那一格写成 `A+B`(例如 `F1+F2`)——**加号左边那个是主单位**(名次与榜单的归属主体),全角 `+` 与两侧空格都认。默认均分;一个联队最多 5 个单位。写法与报错见[报名名单导入规范 · 第三节](./报名名单导入规范.md);
2. **导入之后在界面上改**:到「成绩录入」页**点那一行的「单位」格**(复核页只读,改份额在录入页),打开「成员单位与份额」弹窗。要改成非均分(例如 2:1)**只能在弹窗里改,导入表里写不了**。
**「成员单位与份额」弹窗怎么用**
1. 弹窗里每个成员单位一行:**编码 / 单位全称 / 「主单位」徽标 / 权重输入框 / 移除**;
2. 权重是**相对权重**:都留空(或填 0)就是**均分**(各 50%),填 `2` 与 `1` 就是 **67% 与 33%**;
3. 底部「**添加成员单位…**」下拉里选一个单位再点「添加」——下拉里列的是还没在成员里的单位(**已停用**的单位也可以加,会标注「已停用」;被「彻底删除」的单位不出现);
4. 弹窗下方实时显示 `当前比例:第一分公司 50% · 第二分公司 50%`,两个以上成员时旁边还会给出联队名预览(`第一分公司+第二分公司联队`);
5. **主单位不能去掉**(它的「移除」是灰的):报名挂在它名下,要换主单位只能重新建报名;
6. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。
**要注意的**
- **权限**:读得到成绩的人都能看见这一格;**能改的是录入员及以上**(与录成绩同档)。访客只读时输入框置灰,并写明「当前为访客只读视图」或「服务端未连接」;示例数据模式下不能保存;
- **普通报名完全不受影响**:只有一个单位的报名,这一格的文案、元素与以前一样,**不改动就不会发出任何请求**;
- **分数怎么分、牌怎么记**:保存后这条成绩的分按份额拆给各成员单位(榜单上各处都已经是拆后的数),奖牌**每个成员单位各记一枚**(见上面的口径表与那句提醒);
- **按单位导出名单时,成员单位也看得到这条报名**:用 B 单位筛「报名名单」,能看到主单位是 A、成员里有 B 的联队报名——否则 B 导出自己的名单会发现「没派人上场」;
- ⚠️ **个人积分总明细里,联队里的同一个人会出现两行**(引擎按份额给两个成员单位各留一行流水)。导出侧只在「来源」列写清这两行为什么出现(`成绩(联队分拆:…)`):**看两行加起来才是这个人的总数**,他的总分没有被算两遍;
- ⚠️ **团体名次(见 4.9)要录在「主单位」那一行**:联队的名次录在主单位上,系统才会按份额拆给各成员单位;**不要再给成员单位单独录一份名次**,那会让成员单位再拿一份分(重复计分)。
- ⚠️ **「按队内平均」这类聚合通道的项目,联队不拆份额**:这类通道的名次不对应任何一条报名,分仍记在主单位头上(本轮如此)。要保证联队一起算,请用不聚合的口径(或按 4.9 在主单位上录名次)。
- 一件事只做一次:**同一个小项同一个组别里,两个单位联合参赛就写一条 `A+B` 的报名**。`A+B` 与 `B+A` 是**两条不同的报名**(主单位不同),不要两种写法都填。
---
## 5. 成绩审核
@@ -280,6 +321,9 @@
1. 左侧「待审草稿」按「小项 + 组别」分组,显示每组草稿条数,可搜索小项 / 组别 / 单位;
2. 点选一组后,右侧显示该组成绩明细(单位、运动员 / 队、成绩、名次、状态、积分);
> 「单位」这一列与录入页同一口径:普通报名显示单位简称;**联队**(一条报名挂多个单位)会多一个「联队」小标,并把成员单位与份额逐条列出(`第一分公司 50%` / `第二分公司 50%`),悬停能看到联队全名——复核时一眼就看出这一条其实是几个单位合并报的。本页只读,改份额请到「成绩录入」页点那一行的「单位」格(见 4.11)。
3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化;
4. 「发布影响」列出受影响单位与分值变化;
5. 操作:
@@ -387,6 +431,7 @@
### 8.2 单位奖牌榜 / 个人积分榜
- 单位奖牌榜显示金、银、铜与合计;
- ⚠️ **有联队(两个单位合并参赛)时,各单位的奖牌数加起来会大于实际颁发的奖牌数**——联队的每一枚奖牌**每个成员单位各记一枚整枚**(A、B 联队拿 1 枚金牌,榜上 A 记 1 金、B 记 1 金,场上只颁发了 1 枚)。**这是定稿口径,不是数据错了**;拿榜单去和实际颁发的奖牌核对时请记住这一点。联队本身怎么配见 4.11;
- **单位奖牌榜有自己的名次与顺序:金牌多的在前,其次比银牌、铜牌,最后按单位编码排**——它不看总分,所以「金牌多但总分低」的单位在这里排在前面,在单位总分榜上却靠后,这是口径不同,不是数据错了;
- 个人积分榜显示运动员、单位、个人总分与奖牌数;
- 单位奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**(见 8.1),不是奖牌专属明细;但这份榜单本身有专属的逐枚奖牌导出(见 8.3);
@@ -399,6 +444,11 @@
- **导出奖牌明细**(单位奖牌榜卡片头):导出 `单位奖牌明细.xlsx`——第一张总表逐枚奖牌列出「单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队」,`奖牌` 列是 `金牌` / `银牌` / `铜牌`;其后每个**有奖牌**的单位一张分表(列同上、去掉「单位」,末尾一行小计 `金 X / 银 Y / 铜 Z`),没有奖牌的单位不出分表。口径与「单位奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,不会重复计数;
- **导出个人明细**(个人积分榜卡片头):导出 `个人积分总明细.xlsx`——第一张总表逐条列出「单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源」;其后每个**有得分**的单位一张分表(按单位分,不按人分),没有得分流水的人不出现在表里。`得分` 是记到个人的原始分,**与「个人积分榜」逐个数字一致,且不乘榜单口径里的个人项目得分折算系数**(那个系数只作用于单位总分);
- **导出该单位明细**(右侧单位明细面板头部):先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」)。**备注列会写下修订痕迹**:被改过的成绩写「已修订 ×N;最后一次 <时间> <原因>」,没改过的行留空——与屏幕上那个角标是同一句话,事后拿表格对账不会对不上。单位奖牌榜展开的也是这个单位积分明细面板,所以想按单位逐条看时同样用它;
- **联队(一条报名挂多个单位)在这几份导出件里怎么体现**(见 4.11):
- **单位积分明细 / 单位积分总明细的「备注」列**:这一条是从联队分来的,会写清分给了哪些单位、各拿多少分(多少 %),例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`;「积分」列写的是**本单位实得的那一份**(上例是 5 分,不是联队拿到的 10 分);
- **个人积分总明细的「来源」列**:这一条是联队分来的写成 `成绩(联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%))`。⚠️ 这张表里**联队成员会出现两行**(引擎按份额给两个成员单位各留一行流水)——**看两行加起来才是这个人的总数**,他不是被算了两遍;
- **单位奖牌明细的「奖牌」列**:联队那一枚写成 `金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)`(非联队行仍然只有「金牌」两个字)。为了让这句话写得下,这一列**加宽并允许换行**,格子里的值本身没变;
- **总表 / 单位奖牌明细的副标题**:赛事里有联队时,会追加一句 `· 注:本赛事含联队,奖牌按「每个成员单位各记 1 枚」统计,各单位奖牌数之和会大于实际颁发数`——**各单位奖牌数加起来比实际颁发的多,是定稿口径,不是数据错了**(A、B 联队拿 1 枚金牌,两边榜上各 1 枚);
- 这些导出都是只读下载(访客也能用),不需要写权限;导出过程中按钮会变成「导出中…」并禁用,避免重复下载。
### 8.4 推送明细到大屏
@@ -660,6 +710,8 @@
报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。
> 两个单位因参赛人数不足**合并成一个队参赛**时,不要建一个「联队单位」——**在导入表的「单位编码」列写 `F1+F2`**,或导入后到「成绩录入」页点那一行的「单位」格配成员单位与份额(见 4.11)。榜单仍是原来的单位数,分数与奖牌按份额记给各单位。
> 本页的「从已有名单勾选」在**成绩录入页的工具栏里也有一个入口**(按钮叫 **「补报名」**),打开的是同一套勾选界面,只是预选了你正在录的那个小项与组别——在录入界面把某人移出报名之后,就地补回来即可(见 4.8)。
**从已有名单勾选**
@@ -687,7 +739,9 @@
- 「**中文表头 / 英文表头**」=**单表模板**(`entries-template-zh.csv` / `entries-template-en.csv`):一份表装全部报名,每行都要填「项目编码」与「组别」两列;
- 「**分工作表 · 中文表头 / 分工作表 · 英文表头**」=**分工作表模板**(`.xlsx`):按当前赛事已配好的**小项 × 组别各建一张工作表**,表名是「**小项名称@组别名称**」(例如 `100 米跑@男子组 30岁以下`),范围由表名承担,数据区只填人和单位。小项组别多时用它更不容易抄串;
- 两种模板的表头与列顺序完全一样,只有「范围从哪来」不同(详细规范见 [报名名单导入规范 · 第八节](./报名名单导入规范.md));
- **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `F1+F2`(加号左边是主单位,默认均分,见 4.11)。**分工作表模板**里,「单位编码」表头那一格挂了一条**单元格批注**(鼠标停上去就弹出来)、这一列还有**输入提示**(点中该列的格子就弹出来),中英各一份,写的正是这套写法——表头文字、列顺序与工作表名一个字都没动,**老表格照常导入**;
2. 表头共 12 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)为可选列,缺失不影响导入,其余列必须包含;
- `team_code`(单位编码)有两种填法:**一个单位**填它的编码(`F1`);**两个单位联合参赛**填 `F1+F2`(加号左边是主单位,默认均分;非均分要到录入页改,见 4.11);
- 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过;
- 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失);
- 小项名与组别名里**不允许出现 `@`**(它是表名里的分隔符):新建 / 编辑小项与组别时界面会当场拦下并说明原因;**已经带 `@` 的历史名字不影响报名与成绩**,但下载分工作表模板时会被点名要求先改名;
@@ -757,6 +811,11 @@
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
- **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11):
- **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注;
- **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例);
- **按单位筛选导出时,成员单位也看得到这条报名**:「报名名单」按 F2 筛选,能导出「主单位是 F1、成员里含 F2」的联队报名(否则 F2 导出自己的名单会发现「没派人上场」);
- 「单位积分明细 / 单位积分总明细」「个人积分总明细」「单位奖牌明细」里联队的写法见 8.3;**没有联队的赛事,这些列与升级前逐字一致**。
- **三种「还没出结果」的行也照样占一行**(「分项成绩表」「全部」「全部(单页)」都是,逐格口径完全一致):
- **录了但还没发布**:成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」;
- **完全没有录成绩**(连草稿都没有):名次 / 成绩 / 积分三列都写 `—`(不是空、也不是 `0`)、状态列写「未录入」;
@@ -992,6 +1051,8 @@
> 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。
> **联队(两个单位合并参赛)在大屏上怎么写**(见 4.11):「分项成绩」与「实时赛况」的**单位列写联队全名**——`第一分公司+第二分公司联队`,并且**把两个单位的 logo 都放上**(单位列宽会为该行实际要画的 logo 数留位置,第二个 logo 不会把名字挤成省略号);底栏「最新完成项目」那一行同样写联队全名。**单位奖牌榜、总积分榜本来就是逐单位一行,服务端已按份额拆好**(例:A、B 各 1 枚金、各 7.5 分),屏上不需要另做处理。**没有联队的赛事,大屏画面与升级前逐像素一致**。
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
+91 -1
View File
@@ -6,7 +6,97 @@
---
## dev_0.5.2(当前版本)
## dev_0.5.3(当前版本)
### 一句话
**有一条数据迁移(`0024`),但升级后不用重配任何东西、老数据一个数字都不变**:一条报名现在可以挂**多个单位、按份额分成**——两个单位因参赛人数不足合并成一个联队参赛时,分数按份额拆给各单位(默认均分,允许出现 7.5 这种小数),奖牌**每个成员单位各记一枚整枚**。升级前就有的赛事,榜单、名次、积分、奖牌、导出件与升级前一模一样——**联队能力只有在真的把某条报名配成联队之后才会体现**。不用重新导入规则包。
### ① 数据迁移:一条(`0024`)
- **它做什么**:新建一张 `entry_team_shares` 表,记「这条报名归哪些单位、各占多少」(积分份额 + 奖牌是否记给本单位),并把**已有的每一条报名回填成「主单位独占 100%」**。`entries.team_id` 保留为**主单位**(联队时就是加号左边那个),既有的查询、唯一键、榜单、导入都还认它。
- **只加一张表 + 回填**:不改任何既有字段、不删任何数据;回填用的是「已经有份额就不动」的写法,重复执行(或迁移被重放)**不会覆盖已经配好的联队份额**。
- **怎么看它跑过**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到 `已应用迁移 … name=0024_entry_team_shares.sql`。
### ② 老数据零变化(有对拍证据)
- **无联队的老数据在引擎层、接口层、导出层三层都做过签名 / 逐行对拍,差异 0 处**,签名取自**联队实现之前**的代码,实现之后必须一字不差:
- 引擎层:`server/internal/domain/legacy_forms_golden_test.go`(`TestLegacyFormsScoringUnchanged`,签名 `a2aa1c97…`);
- 接口层:`server/internal/httpapi/legacy_forms_golden_test.go`(`TestLegacyFormsAPISnapshotUnchanged`,签名 `aa6cea37…`);
- 导出层:`server/internal/export/joint_team_export_test.go` 的 `TestSingleUnitExportsHaveNoJointTeamText`(只有一条独占份额时,各份导出件一个字都不能多)。
- 所以:**升级后单位总分榜、单位奖牌榜、个人积分榜的每一个数字、每一条名次、每一枚奖牌、每一份导出件都与升级前一模一样**。本事只在真的把某条报名配成联队之后才生效。
### ③ 不用重新导入规则包
- 本版**没有改规则包格式、没有改计分口径、没有改任何计分通道配置**:随包规则包与现场那份都不用动,也不需要重算。
- (对照:dev_0.5.1 那次是建议重新导入的,本版不是。)
### ④ 新增能力:联队 + 份额分成(口径已定稿)
| 口径 | 定稿写法 |
| --- | --- |
| 份额 | **可配、默认均分**(权重都留空 = 各 50%;填 2 与 1 = 67% 与 33%) |
| 积分 | **平均拆,接受 .5**:团体第 1 名 15 分 → 两个单位各 **7.5 分**(榜单上就写 7.5) |
| 奖牌 | **每个成员单位各记一枚整枚**:联队的金牌,两个单位**各记 1 枚**(不是各 0.5 枚) |
| 显示 | 联队的名字**都写出来**:`第一分公司+第二分公司联队` |
| 导入 | 报名表的**单位编码**列写 `A+B`(加号左边那个是**主单位**;全角 `+` 与两侧空格等价) |
怎么用:
- **配联队的两个入口**:
1. **导入报名表**时把单位编码写成 `A+B`(默认均分;最多 5 个单位;`A+`、`+B`、`A++B`、`A+A` 这些写法会被**逐行拦下**并说清是第几行、错在哪,**其余行照常导入**);报名模板(分工作表 xlsx)的「单位编码」表头挂了一条**单元格批注**、该列还有**输入提示**,中英各一份,内容就是这套写法——**表头文字、列序、12 列、工作表命名一个字都没动**,老表格照常导入;
2. 导入后到**「成绩录入」页点那一行的「单位」格**,打开「**成员单位与份额**」弹窗改(加成员单位、填权重、去掉成员)。⚠️ **非均分(例如 2:1)不能在导入表里写**,只能在弹窗里改。
- **谁能改**:份额编辑与录成绩同档,**录入员及以上**;访客只读时输入框置灰并写明原因。
- **改完当场生效**:保存立即重算该小项该组别并推送大屏。
- **录入与复核页看得到**:联队的单位格显示成 `联队 第一分公司 50% 第二分公司 50%`(非均分时带 `(权重 2)`)。**普通报名(只有一个单位)的格子文案、元素一个字没动**,只是多了一个可点的入口和一行悬停提示。
- **大屏**:分项成绩表与实时赛况的「单位」列写 `第一分公司+第二分公司联队`,并**把两个单位的 logo 都放上**(单位列宽会为该行的 logo 数留位);底栏「最新完成项目」同样写联队全名;单位奖牌榜与总积分榜本来就是逐单位一行,服务端已按份额拆好(例:A、B 各 1 枚金、各 7.5 分)。
### ⑤ 导出件里现在写成什么样
| 位置 | 现在写成 |
| --- | --- |
| 分项成绩表 / 全部成绩(单页)「单位」列 | `第一分公司+第二分公司联队` |
| 同上「备注」列 | `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)` |
| 报名名单「单位编码 / 单位名 / 备注」 | `T1+T2` / `第一分公司+第二分公司联队` / `联队分拆:第一分公司 50%、第二分公司 50%` |
| 单位积分明细 / 总明细「备注」列 | `联队分拆:… 各多少分(各多少 %)` |
| 个人积分总明细「来源」列 | `成绩(联队分拆:…)` |
| 单位奖牌明细「奖牌」列 | `金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)` |
| 总表 / 单位奖牌明细的副标题 | 追加 `· 注:本赛事含联队,奖牌按「每个成员单位各记 1 枚」统计,各单位奖牌数之和会大于实际颁发数` |
- **按单位筛选导出**:成员单位的名单里也能看到**主单位是另一个单位的联队报名**(否则 B 导出自己的名单会发现「没派人上场」);
- 导出件的**值只在真的有联队时才变**:没有联队的赛事,导出件与升级前逐字一致。
### ⑥ 两条要主动告知的口径后果
1. **各单位的奖牌数之和会大于实际颁发的奖牌数**——因为联队的每一枚奖牌**每个成员单位各记 1 枚**。例:A、B 联队拿 1 枚金牌,奖牌榜上 A 记 1 金、B 记 1 金,而场上只颁发了 1 枚。**这是定稿口径,不是数据错了**;含联队的「总表 / 单位奖牌明细」副标题会写这句话,奖牌明细里联队那一格也写着「各单位各记 1 枚」。**拿奖牌数去和实际颁发的奖牌核对时请记住这一点。**
2. **个人积分总明细里,联队里的同一个人会出现两行**——引擎按份额给两个成员单位各留了一行流水,导出侧只在「来源」列写清这两行为什么出现(`成绩(联队分拆:…)`)。**看两行加起来才是这个人的总数**;这不是重复计分(他的总分没有被算两遍),是按份额拆开的结果。
### ⑦ 顺带:单位奖牌明细的「奖牌」列加宽并允许换行
- 导出件「单位奖牌明细」的「奖牌」列原来是**宽 8、居中**,写不下 `金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)` 那句(会被右边的「运动员/队」裁掉),现在**加宽到 26 并允许换行**。
- **列里的值没变**:没有联队时这一格仍然只是「金牌」「银牌」「铜牌」三个字。
### ⑧ 升级后 5 分钟核对
1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0024` 这条迁移记录,没有别的报错;
2. 刷新管理端「积分榜」与大屏:**单位总分榜、单位奖牌榜、个人积分榜的每一个数字与升级前一致**(本版不改任何计分口径;只有你之后自己配了联队的地方才会变);
3. 打开「成绩录入」页:普通报名的**「单位」那一格与升级前长得一样**(只是鼠标移上去变成手型、有一行悬停提示,点一下会打开「成员单位与份额」弹窗,什么都不改直接关掉等于没动过);
4. 打开「基础数据 → 报名导入」,下载一份「**分工作表 · 中文表头**」,把鼠标停在表头「**单位编码**」那一格上:应弹出「单位编码怎么写」的批注(内容含 `A+B`);点中该列任一格应弹出同样的输入提示;
5. 真要用联队的:按 ④ 把那条报名配好(导入写 `A+B`,或点单位格改份额),确认榜单上两个单位**各拿到一半分、各记一枚牌**、大屏上两个单位的 logo 都在;
6. 用《上线检查清单》过一遍。
### ⑨ 详细说明在哪
| 想了解 | 看 |
| --- | --- |
| 联队怎么录、份额怎么改、分成后长什么样 | 《使用手册》4.11 |
| 导入表里联队怎么写、写错了报什么错 | 《报名名单导入规范》第三节的「单位编码」、第六节 |
| 大屏与导出件上联队写成什么 | 《使用手册》17.1、8.3、11.1 |
| 迁移与份额表结构 | `server/migrations/event/0024_entry_team_shares.sql` |
---
## dev_0.5.2
### 一句话
+61 -3
View File
@@ -54,7 +54,7 @@
|---|---|---|---|
| `event_code`(项目编码) | **必须** | 小项编码,抄系统里配好的 | `M100` |
| `category`(组别) | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
| `team_code`(单位编码) | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
| `team_code`(单位编码) | **必须** | 参赛单位编码,抄系统里配好的。**两个单位联合参赛(联队)就写 `A+B`**,见第三节 | `F1`;联队写 `F1+F2` |
| `athlete_name`(姓名) | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
| `id_card`(证件号) | 可选 | 证件号(身份证号)。**系统的第一认人依据**;一个证件号只能属于一个人 | `110101199001011234` |
| `gender`(性别) | 可选 | 性别,只写「男」或「女」 | `男` |
@@ -88,6 +88,39 @@
在系统「基础数据 → 参赛单位」里给每个大单位配的**编码**,比如 `F1`、`F2`。系统里没有的会提示「单位 XXX 不存在」。
被「彻底删除」的单位,导入时若再次用到它的编码,它会**重新出现(复活)**,这一行照常导入——单位编码是唯一的,系统按编码复用原来那条记录,它以前的报名、成绩与积分都还在。
**两个单位联合参赛(联队)怎么写**
同一小项组别里,两个单位合起来出一支队参赛时,**这一格写两个编码、中间用 `+` 连起来**:
```
F1+F2
```
- **加号左边那个是「主单位」**:名次与榜单的归属主体就是它。`F1+F2` 与 `F2+F1` 是**两条不同的报名**(主单位不同),同一组别里只写一条即可;
- **全角 `+` 与两侧空格都认**:`F1+F2`、`F1 + F2` 与 `F1+F2` 是同一个意思(中文输入法下打出来的常是全角);
- **默认均分**:两个单位各占一半(50% / 50%)。团体第 1 名 15 分 → 两个单位**各 7.5 分**;奖牌**两边各记一枚整枚**(联队的金牌,F1、F2 各记 1 枚);
- ⚠️ **想改成非均分(例如 2:1)不能在表里写**:导入表只表达「是哪几个单位」。份额要等导入完到「成绩录入」页**点那一行的「单位」格**,在「成员单位与份额」弹窗里改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛));
- **一个联队最多 5 个单位**;成员单位一个都不能写错——写错的那一行会被拦下,其余行照常导入;
- **加号两边都必须有编码**,而且同一个单位不能写两遍。
**怎么写会被拦下(逐行报错,其余行照常导入)**
| 写成 | 报错 |
|---|---|
| `F1+` / `+F2` / `F1++F2` | `第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B)` |
| `F1+F1` | `第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次` |
| `F1+F2+F3+F4+F5+F6` | `第 N 行:单位编码 F1+F2+F3+F4+F5+F6 里写了 6 个单位:一个联队最多 5 个单位` |
| `F1+ZZ`(`ZZ` 不存在) | `第 N 行:单位 ZZ 不存在(来自联队编码 F1+ZZ)` |
| `ZZ`(单个单位,写法没变) | `第 N 行:单位 ZZ 不存在` |
> 报错都写在**行号**上,并把你那一格**原样打出来**——照那句话改那一行即可,不用重传整张表。
**导入之后长什么样**
- 联队仍是一条报名,但**分与奖牌都按份额记给各成员单位**:单位总分榜、单位奖牌榜上 A、B 各有一份;
- 大屏和导出件里写的是**联队的名字**:`第一分公司+第二分公司联队`,报名名单导出的「单位编码」列也回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位);
- 用成员单位筛「报名名单」导出时,**也能看到这条联队报名**(否则 B 导出自己的名单会以为没派人上场)。
### `athlete_name` 运动员姓名
- **个人项目**:填姓名,必填。
- **团体项目**:**这一列留空**,表示"这是某个单位的一条团体报名",队员写在 `member_names` 里。
@@ -163,7 +196,7 @@
---
## 四、三个完整例子
## 四、四个完整例子
### 例 1:个人项目(七单位 × 若干人)
@@ -195,6 +228,24 @@
这类项目(比如团体引体向上)**按个人报名填**:每人一行、填姓名和单位。系统会自己算两份成绩——个人的名次分记在个人身上,团体分按各队成员的**平均成绩**排名后算给单位。**不需要**再单独写团体行。
### 例 4:两个单位联合参赛(联队)
本届运动会 6 项球类比赛里,**F2 与 F5 因参赛人数不足,合并出一个队参赛**。`BALL` 是篮球、`男子组`:
| event_code | category | team_code | athlete_name | id_card | gender | age | bib | seed | heat | status | member_names |
|---|---|---|---|---|---|---|---|---|---|---|---|
| BALL | 男子组 | F1 | | | | | | | | registered | 张三/李四 |
| BALL | 男子组 | F2+F5 | | | | | | | | registered | 王五/赵六/钱七 |
| BALL | 男子组 | F3 | | | | | | | | registered | 孙八 |
要点:
- **写一条 `F2+F5` 的报名就够了**,不要 F2、F5 各写一条(那会变成两队人上场);也不要新建一个叫「F2F5」的单位;
- `F2` 在**加号左边**,所以 **F2 是主单位**:这一条的名次与榜单以它为主体。写成 `F5+F2` 是另一条报名(主单位变成 F5),同一组别里只写一种;
- 默认**均分**:这条成绩拿到的分,F2 与 F5 **各一半**(团体第 1 名 15 分 → 各 7.5 分);奖牌**两边各记一枚整枚**(一块金牌,F2 榜上 1 枚、F5 榜上 1 枚);
- 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛));
- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。
---
## 五、导入前请先检查(自检清单)
@@ -210,6 +261,7 @@
- [ ] 每个证件号在系统里只属于一个人,别和别人的档案重号
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
- [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节)
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
- [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节)
@@ -228,6 +280,10 @@
| 无法解析 CSV:表格里多半有没配对的双引号 | 格子里**手工**写了双引号,或只有一个引号没配对 | 让 Excel 重新另存一次(引号由 Excel 按规则加,别手工写);确实需要引号的,写成 `""` 转义 |
| 第 N 行:小项 XXX 不存在 | `event_code` 写错,或该小项还没在系统里配 | 到「基础数据 → 大项 / 小项」核对编码;没配的先配 |
| 第 N 行:单位 XX 不存在 | `team_code` 写错,或该单位还没配 | 到「基础数据 → 参赛单位」核对 |
| 第 N 行:单位 ZZ 不存在(来自联队编码 F1+ZZ) | 这一格写的是**联队** `F1+ZZ`,其中 `ZZ` 没配 | 核对加号右边那个编码(括号里把**整格**原样打出来了,一眼看出是哪一段没对上);不改联队写法的话,就照单单位的办法核对编码 |
| 第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B) | **加号有一边是空的**(`F1+`、`+F2`、`F1++F2` 都是这一条)。多半是填一半或多了个加号 | 补上缺的那个编码,或删掉多余的加号;联队至少两个单位 |
| 第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次 | 同一个单位在联队里写了两遍(`F1+F1`) | 一个单位只写一次;两个单位联合参赛就是 `F1+F2` |
| 第 N 行:单位编码 … 里写了 6 个单位:一个联队最多 5 个单位 | 加号太多、成员超过 5 个 | **一个联队最多 5 个单位**;现场最多见过三个单位合并,超过 5 个多半是这一格填错了东西(例如整格都是加号) |
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
@@ -292,6 +348,7 @@
- **「项目编码」「组别」两列可以整列留空**——表名已经说明了是哪个小项、哪个组别;
一旦填了,就**必须与表名一致**,不一致的那一行会报错并跳过(系统不猜、也不会挑一个用);
- 其余列与单表模板完全一样(第三节逐列说明照样适用);
- **下载下来的模板自带「联队怎么写」的说明**:分工作表模板里,「单位编码」**表头那一格有单元格批注**(鼠标停上去弹出,右边带一个小红三角),**该列每一格还有输入提示**(点中格子就弹出来);中文表头、英文表头各一份,内容就是 `A+B` 这套写法(加号左边是主单位、积分平均分、奖牌两边各记一枚)。**说明只挂在批注与输入提示上,不占任何一格数据、也不改表头与列序**——填好的表原样传回来照样导入;
- **只填了表头、没填人的那张表**会被跳过并给一条提示(= 这个组别没人报名),其余表照常导入;
- 不要在工作簿里加「说明」之类的工作表:**只要工作簿里有一张表名带 `@`**,整份文件就按分工作表模板处理,
其它表名(例如 `Sheet1`、`备注`)必须也是「小项名称@组别名称」的形状,否则**整份拒绝**并点名是哪张表——
@@ -337,6 +394,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿
> 7. 表里**不要合并单元格**、不要加标题行、不要插图;
> 8. 填完先对着第五节的清单自查一遍,再发给收表人;
> 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**;
> 10. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。
> 10. **两个单位联合参赛(联队)**:`team_code` 那一格写 `F1+F2`(加号左边是主单位,默认均分,最多 5 个单位),**不要**新建一个「联队单位」、也不要两个单位各写一行;非均分(例如 2:1)要在导入后到「成绩录入」页点那一行的「单位」格改;
> 11. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。
>
> 一句话:**下载模板 → 只往下面粘数据 → 另存为 CSV UTF-8 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。
+3 -1
View File
@@ -82,6 +82,8 @@ type participant struct {
status ResultStatus
entryStatus EntryStatus
state ResultState
// shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。
shares []TeamShare
}
func fixture(ps []participant) ([]Entry, []Result) {
@@ -91,7 +93,7 @@ func fixture(ps []participant) ([]Entry, []Result) {
entries = append(entries, Entry{
ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID,
AthleteID: p.athleteID, AthleteName: p.name,
Status: p.entryStatus,
Status: p.entryStatus, TeamShares: p.shares,
})
state := p.state
if state == "" {
+377
View File
@@ -0,0 +1,377 @@
package domain
import (
"testing"
)
// 本文件钉住「联队 + 份额分成」的算分口径(devdocs/19 第三节的五条拍板)。
//
// 两个口径必须分开表达,测试也分成两组:
// - 积分按份额拆:团体第 1 名 15 分 → 联队的两个单位各 7.5 分(允许出现小数);
// - 奖牌整枚各记:同一个金牌 → 两个单位**各记 1 枚**(不是各 0.5 枚)。
//
// 另外单钉一处最容易错的口径:份额与 count_to_team / count_to_medals 两个通道开关叠加。
// 单位总分公式是 `Σ仅记单位的流水 + 全局系数 × Σ(记到个人且 count_to_team=1 的流水)`,
// 份额只改「这一份分记在哪个单位头上」,两个开关仍然按通道整体决定这条流水算不算数。
// jointShares 构造联队份额:两个单位均分(默认口径),奖牌各记整枚。
func jointShares(teamIDs ...int64) []TeamShare {
out := make([]TeamShare, 0, len(teamIDs))
for _, id := range teamIDs {
out = append(out, TeamShare{TeamID: id})
}
return out
}
// weightedShares 构造按给定权重拆分的份额(奖牌缺省 = 整枚各记)。
func weightedShares(pairs ...any) []TeamShare {
out := make([]TeamShare, 0, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
out = append(out, TeamShare{TeamID: pairs[i].(int64), Weight: pairs[i+1].(float64)})
}
return out
}
// medalOf 取单位奖牌榜上某单位的金银铜。
func medalOf(rows []MedalRow, teamID int64) (gold, silver, bronze int) {
for _, r := range rows {
if r.TeamID == teamID {
return r.Gold, r.Silver, r.Bronze
}
}
return 0, 0, 0
}
// teamMedalRows 用一份「引擎产出」直接算单位奖牌榜。
func teamMedalRows(t *testing.T, ledger []LedgerEntry, ranked []RankedResult, teamIDs ...int64) []MedalRow {
t.Helper()
return BuildMedalStandings(StandingsInput{
Teams: testTeams(teamIDs...),
Ledger: ledger,
Ranked: ranked,
})
}
// scoreTeamManualWithEntries 用团体手工名次通道跑一次计分:名次按单位录(联队录在主单位上),
// 报名项由调用方给(联队的名次想按份额拆,库里得先有那条联队报名)。
func scoreTeamManualWithEntries(t *testing.T, entries []Entry, ranks ...TeamManualRank) EventScore {
t.Helper()
return mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(9)},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries,
TeamRanks: ranks,
})
}
// 口径 2:团体第 1 名 15 分,联队的两个单位各 7.5 分(奇数分平均拆,允许 .5)。
func TestJointTeamSplitsPointsEvenly(t *testing.T) {
// 团体报名项(athlete_id=0)+ 份额 [A,B],名次录在主单位 A 上。
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队第 1 名应各得 7.5 分,得到 A=%v B=%v(流水:%+v)", pts[1], pts[2], es.Ledger)
}
// 拆出来的份数之和必须正好等于原来那一份,否则「一个单位改成联队」会悄悄改变总分。
if !nearlyEqual(pts[1]+pts[2], 15) {
t.Fatalf("两份之和应等于 15,得到 %v", pts[1]+pts[2])
}
// 每一条份额各是一条流水(各有 team_id),名次本身只有一条。
if len(es.Ledger) != 2 {
t.Fatalf("应产出 2 条流水(A、B 各一条),得到 %d 条:%+v", len(es.Ledger), es.Ledger)
}
for _, l := range es.Ledger {
if l.AthleteID != 0 {
t.Fatalf("团体通道的流水不该挂个人:%+v", l)
}
}
if len(es.Ranked) != 1 || len(es.Ranked[0].Shares) != 2 {
t.Fatalf("名次明细应只有一条、带两份份额:%+v", es.Ranked)
}
}
// 名次直接录在联队报名项上(成绩录入页填名次,不走按单位录的团体名次)也必须按份额拆。
func TestJointTeamSplitsPointsFromEntryRank(t *testing.T) {
entries, results := fixture([]participant{{
entryID: 1, teamID: 1, shares: jointShares(1, 2), rank: 1, noValue: true,
}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(9)},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, Results: results,
})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队报名项上的第 1 名应各得 7.5 分,得到 A=%v B=%v", pts[1], pts[2])
}
}
// 口径 3:奖牌整枚各记——联队拿到的金牌,两个单位各记 1 枚完整金牌。
func TestJointTeamMedalsAreWholeForEachMember(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2, 3)
if gold, _, _ := medalOf(rows, 1); gold != 1 {
t.Fatalf("主单位 A 应记 1 枚金牌,得到 %d 枚(%+v)", gold, rows)
}
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("成员单位 B 应记 1 枚金牌(不是 0.5 枚),得到 %d 枚(%+v)", gold, rows)
}
if gold, _, _ := medalOf(rows, 3); gold != 0 {
t.Fatalf("没参赛的单位不该有奖牌,得到 %d 枚", gold)
}
}
// 可配权重:2:1 时积分按 2:1 拆,奖牌仍是各 1 枚(两个口径互不影响)。
func TestJointTeamWeightsAreConfigurable(t *testing.T) {
entries, _ := fixture([]participant{{
entryID: 1, teamID: 1, shares: weightedShares(int64(1), 2.0, int64(2), 1.0),
}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 10) || !nearlyEqual(pts[2], 5) {
t.Fatalf("2:1 的权重应拆成 10 / 5,得到 A=%v B=%v(流水:%+v)", pts[1], pts[2], es.Ledger)
}
if !nearlyEqual(pts[1]+pts[2], 15) {
t.Fatalf("2:1 拆分后两份之和应仍是 15,得到 %v", pts[1]+pts[2])
}
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("权重只影响积分,奖牌仍是整枚各记:B 应 1 枚金牌,得到 %d 枚", gold)
}
}
// 份额上的 medal 开关:显式关掉时该单位不记这枚奖牌(积分不受影响)。
func TestJointTeamMedalCanBeTurnedOffPerMember(t *testing.T) {
noMedal := false
shares := []TeamShare{{TeamID: 1, Medal: &noMedal}, {TeamID: 2}}
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: shares}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("显式关掉奖牌的成员单位不该记牌,得到 %d 枚", gold)
}
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("没关掉的那个单位仍记 1 枚,得到 %d 枚", gold)
}
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("奖牌开关不该影响积分分配,得到 A=%v B=%v", pts[1], pts[2])
}
}
// 份额 × 通道开关(count_to_team / count_to_medals)的叠加矩阵。
//
// 这一处最容易错:份额只改「这一份记在哪个单位头上」,两个开关仍然按通道整体决定
// 这条流水/名次算不算数。四种组合都必须能单独说清。
func TestJointTeamSharesWithChannelSwitches(t *testing.T) {
countToTeam := func(v bool) *bool { return &v }
t.Run("团体通道 + count_to_team=1:两份都进单位总分", func(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1].Total, 7.5) || !nearlyEqual(sums[2].Total, 7.5) {
t.Fatalf("两个单位各应得 7.5 分(仅记单位通道),得到 A=%+v B=%+v", sums[1], sums[2])
}
if !nearlyEqual(sums[1].TeamOnly, 7.5) {
t.Fatalf("团体通道的流水应记在「仅记单位」一栏,得到 %+v", sums[1])
}
})
t.Run("团体通道 + count_to_team=0:流水照记,但不进任何单位的总分", func(t *testing.T) {
ch := teamManualChannel(9)
ch.CountToTeam = countToTeam(false)
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, TeamRanks: []TeamManualRank{{TeamID: 1, Rank: 1}},
})
if len(es.Ledger) != 2 {
t.Fatalf("流水要照记(每条份额一条),得到 %d 条", len(es.Ledger))
}
for _, l := range es.Ledger {
if l.CountToTeam {
t.Fatalf("通道关掉计入单位总分后,流水上的 count_to_team 应为 false:%+v", l)
}
}
if sums := SumByTeam(es.Ledger, 1); len(sums) != 0 {
t.Fatalf("关掉 count_to_team 后不该有任何单位分,得到 %+v", sums)
}
})
t.Run("个人通道 + count_to_team=1:两份都按个人分进各自单位(乘系数)", func(t *testing.T) {
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1].Individual, 5) || !nearlyEqual(sums[2].Individual, 5) {
t.Fatalf("个人通道的两份应各进各自单位的个人分(各 5),得到 A=%+v B=%+v", sums[1], sums[2])
}
// 系数只乘一次:单位总分 = 系数 × 个人分,联队不会把这条成绩算两遍。
if sums := SumByTeam(es.Ledger, 0.5); !nearlyEqual(sums[1].Total, 2.5) {
t.Fatalf("系数 0.5 下 A 的总分应为 2.5,得到 %v", sums[1].Total)
}
// 个人榜不受份额影响:这两条流水加起来还是原来那 10 分。
if ath := SumByAthlete(es.Ledger); !nearlyEqual(ath[101], 10) {
t.Fatalf("个人榜上该运动员应仍是 10 分,得到 %v", ath[101])
}
})
t.Run("个人通道 + count_to_team=0:只记个人榜,不进任何单位总分", func(t *testing.T) {
ch := athleteResultChannel(9)
ch.CountToTeam = countToTeam(false)
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
if sums := SumByTeam(es.Ledger, 1); len(sums) != 0 {
t.Fatalf("count_to_team=0 的个人通道不该进单位总分,得到 %+v", sums)
}
if ath := SumByAthlete(es.Ledger); !nearlyEqual(ath[101], 10) {
t.Fatalf("个人榜照旧记 10 分,得到 %v", ath[101])
}
})
t.Run("count_to_medals=0:不发奖牌(积分照发)", func(t *testing.T) {
ch := teamManualChannel(9)
no := false
ch.CountToMedals = &no
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, TeamRanks: []TeamManualRank{{TeamID: 1, Rank: 1}},
})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("关掉奖牌通道后 A 不该有金牌,得到 %d 枚", gold)
}
if gold, _, _ := medalOf(rows, 2); gold != 0 {
t.Fatalf("关掉奖牌通道后 B 不该有金牌,得到 %d 枚", gold)
}
if pts := pointsByTeam(es.Ledger); !nearlyEqual(pts[1], 7.5) {
t.Fatalf("奖牌开关不影响积分:A 应仍是 7.5,得到 %v", pts[1])
}
})
t.Run("个人通道 + count_to_team_medals=0:个人榜有牌、单位榜没有", func(t *testing.T) {
ch := athleteResultChannel(9)
no := false
ch.CountToTeamMedals = &no
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("个人奖牌不并入单位榜时 A 不该有金牌,得到 %d 枚", gold)
}
ath := BuildAthleteStandings(StandingsInput{
Athletes: []Athlete{{ID: 101, Name: "选手A", TeamID: 1}},
Ledger: es.Ledger, Ranked: es.Ranked,
})
if len(ath) != 1 || ath[0].Gold != 1 {
t.Fatalf("个人榜上该运动员仍应有 1 枚金牌,得到 %+v", ath)
}
})
}
// 联队报名不影响普通报名项:同一个小项里既有联队又有单单位时,两边各按自己的份额落分。
func TestJointTeamCoexistsWithSingleTeamEntries(t *testing.T) {
// 项目按成绩升序排名(individualEvent 的 compare=asc),联队跑得更好,拿第 1 名。
entries, results := fixture([]participant{
{entryID: 1, teamID: 1, shares: jointShares(1, 2), value: 80},
{entryID: 2, teamID: 3, value: 90},
})
ev := teamEvent(1)
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{{ID: 4, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 9}},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, Results: results,
})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队第 1 名应各得 7.5,得到 A=%v B=%v", pts[1], pts[2])
}
if !nearlyEqual(pts[3], 10) {
t.Fatalf("单独报名的第 2 名应独得 10 分,得到 %v", pts[3])
}
}
// 没有配份额(老数据、普通报名)时引擎的产出必须与升级前逐位一致:
// 一条流水、整份分、整枚牌,且拆分的浮点计算不能引入零头。
func TestWholeShareStaysExact(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 7}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 7, Rank: 1})
if len(es.Ledger) != 1 {
t.Fatalf("独占 100%% 只该产出一条流水,得到 %d 条:%+v", len(es.Ledger), es.Ledger)
}
if got := es.Ledger[0].Points; got != 15 {
t.Fatalf("独占时的积分应逐位等于 15,得到 %v", got)
}
if got := es.Ranked[0].Shares; len(got) != 1 || got[0].TeamID != 7 || got[0].Weight != 1 || got[0].Medal != nil {
t.Fatalf("独占时的份额应与升级前完全一致(TeamID=7 Weight=1 Medal=nil),得到 %+v", got)
}
rows := teamMedalRows(t, es.Ledger, es.Ranked, 7, 8)
if gold, _, _ := medalOf(rows, 7); gold != 1 {
t.Fatalf("独占时该单位应记 1 枚金牌,得到 %d 枚", gold)
}
}
// splitSharePoints 的边界:权重非法(0 / 负数)按 1 处理,拆分之和恒等于总分。
func TestSplitSharePointsKeepsTotal(t *testing.T) {
cases := []struct {
name string
total float64
shares []TeamShare
want []float64
}{
{"独占", 15, []TeamShare{{TeamID: 1, Weight: 1}}, []float64{15}},
{"均分(奇数分)", 15, []TeamShare{{TeamID: 1}, {TeamID: 2}}, []float64{7.5, 7.5}},
{"2:1(除不尽)", 15, weightedShares(int64(1), 2.0, int64(2), 1.0), []float64{10, 5}},
{"三单位均分", 15, []TeamShare{{TeamID: 1}, {TeamID: 2}, {TeamID: 3}}, []float64{5, 5, 5}},
{"三点均分", 10, []TeamShare{{TeamID: 1}, {TeamID: 2}, {TeamID: 3}}, []float64{10.0 / 3, 10.0 / 3, 10.0 - 2*(10.0/3)}},
{"零权重按 1 处理", 15, weightedShares(int64(1), 0.0, int64(2), 1.0), []float64{7.5, 7.5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := splitSharePoints(tc.total, tc.shares)
if len(got) != len(tc.want) {
t.Fatalf("份数 = %d,期望 %d(%v)", len(got), len(tc.want), got)
}
sum := 0.0
for i := range tc.want {
if !nearlyEqual(got[i], tc.want[i]) {
t.Errorf("第 %d 份 = %v,期望 %v(完整 %v)", i+1, got[i], tc.want[i], got)
}
sum += got[i]
}
if !nearlyEqual(sum, tc.total) {
t.Errorf("拆分之和 = %v,期望 %v(零头会让榜单印出 9.999999999999998)", sum, tc.total)
}
})
}
}
@@ -0,0 +1,240 @@
package domain
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
)
// 本文件是「无联队的老数据在积分、奖牌、榜单上逐字节不变」的硬门槛回归(devdocs/19 §五)。
//
// 覆盖四种形态:
// - 普通个人:个人成绩通道;
// - 纯团体:按单位录名次的团体手工通道(球类项目走的就是它);
// - 双计:individual_team 的个人通道(不计入单位总分)+ 团体聚合通道;
// - 团体报名项双通道:团体报名项(athlete_id=0)+ 个人成绩通道 + 团体手工通道,
// 并单独覆盖「团体报名项没有名次、名次来自按单位录入的覆盖值」这一支
// (联队改动正好动了这里:有联队报名才按份额拆,没有就还是单位独占)。
//
// 签名取自**联队实现之前**的代码(在 git archive HEAD 的修前副本上跑出来的),实现之后必须一字不差。
// 签名文本只写「积分 / 名次 / 归属单位 / 榜单」这些事实,不写结构体形状:
// 份额按 `单位ID:权重` 打印,于是给 TeamShare 加字段(奖牌整枚开关)不会把这份签名带跑偏。
const legacyFormsGoldenSHA256 = "a2aa1c977f68b0943eade20ac4c54de51cde157c495c9104c80b0bbfca5d65ff"
// TestLegacyFormsScoringUnchanged 是本次改动的硬门槛:老数据(没有联队)四形态逐字节不变。
func TestLegacyFormsScoringUnchanged(t *testing.T) {
dump := legacyFormsDump(t)
// 防空转:四种形态都必须真的留下痕迹,否则签名只是一份空文本。
for _, marker := range []string{"individual ", "team-manual ", "individual-team ", "team-entry ",
"team-entry-override ", "team-entry-override-only ", "aggregate-valid ", "aggregate-all "} {
if !strings.Contains(dump, marker) {
t.Fatalf("签名里缺少形态 %q,对拍没有覆盖到它:\n%s", marker, dump)
}
}
sum := sha256.Sum256([]byte(dump))
got := hex.EncodeToString(sum[:])
if got != legacyFormsGoldenSHA256 {
t.Fatalf("无联队的老数据在积分 / 奖牌 / 榜单上变了:\n签名 = %s\n期望 = %s\n\n%s",
got, legacyFormsGoldenSHA256, dump)
}
}
// legacyFormsDump 把四种形态的引擎产出与榜单拼成一份稳定文本。
//
// 稳定性来源:流水与名次按切片顺序打印(引擎的输出顺序本身就是确定的),
// 排行榜的行顺序是领域函数排序的结果(末位都有 ID 兜底,是全序),不依赖 map 遍历顺序。
func legacyFormsDump(t *testing.T) string {
t.Helper()
var lines []string
// 个人榜的运动员信息由引擎产出反推(与读侧从库里查同一批人等价),
// 这样签名里不会混进测试自己编的单位归属。
athletesOf := func(es EventScore) []Athlete {
seen := make(map[int64]bool)
var out []Athlete
for _, r := range es.Ranked {
if r.AthleteID == 0 || seen[r.AthleteID] {
continue
}
seen[r.AthleteID] = true
out = append(out, Athlete{ID: r.AthleteID, Name: r.AthleteName, TeamID: r.TeamID})
}
return out
}
appendScore := func(name string, es EventScore, teams []Team, athletes []Athlete) {
athletes = athletesOf(es)
for _, l := range es.Ledger {
lines = append(lines, fmt.Sprintf(
"%s ledger team=%d athlete=%d entry=%d result=%d source=%s/%d points=%v countToTeam=%v voided=%v",
name, l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.SourceType, l.SourceID,
l.Points, l.CountToTeam, l.Voided))
}
for _, r := range es.Ranked {
lines = append(lines, fmt.Sprintf(
"%s ranked key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v shares=%s medals=%v/%v/%v",
name, r.SubjectKey, r.Scope, r.Basis, r.TeamID, r.AthleteID, r.Rank, r.Points,
formatShares(r.Shares), r.AwardMedals, r.CountToMedals, r.CountToTeamMedals))
}
for _, weight := range []float64{1, 0.5} {
for _, exclude := range []bool{false, true} {
in := StandingsInput{
Teams: teams, Athletes: athletes, Ledger: es.Ledger, Ranked: es.Ranked,
IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude,
}
for _, row := range BuildTeamStandings(in) {
lines = append(lines, fmt.Sprintf(
"%s standings w=%v ex=%v rank=%d team=%d pts=%v only=%v indiv=%v %d/%d/%d medals=%d",
name, weight, exclude, row.Rank, row.TeamID, row.Points, row.TeamOnlyPoints,
row.IndividualPoints, row.Gold, row.Silver, row.Bronze, row.Medals))
}
for _, row := range BuildMedalStandings(in) {
lines = append(lines, fmt.Sprintf(
"%s medalboard w=%v ex=%v rank=%d team=%d pts=%v %d/%d/%d total=%d",
name, weight, exclude, row.Rank, row.TeamID, row.Points,
row.Gold, row.Silver, row.Bronze, row.Total))
}
for _, row := range BuildAthleteStandings(in) {
lines = append(lines, fmt.Sprintf(
"%s athleteboard w=%v ex=%v rank=%d athlete=%d team=%d pts=%v %d/%d/%d medals=%d",
name, weight, exclude, row.Rank, row.AthleteID, row.TeamID, row.Points,
row.Gold, row.Silver, row.Bronze, row.Medals))
}
}
}
}
// 形态 1 · 普通个人:一个单位一个人,个人成绩通道(含并列)。
{
teams := testTeams(1001, 1002, 1003, 1004, 1005, 1006)
entries, results := fixture([]participant{
{entryID: 1, athleteID: 101, teamID: 1001, name: "选手A", value: 11.2},
{entryID: 2, athleteID: 102, teamID: 1002, name: "选手B", value: 11.5},
{entryID: 3, athleteID: 103, teamID: 1003, name: "选手C", value: 11.5},
{entryID: 4, athleteID: 104, teamID: 1004, name: "选手D", value: 12.0},
{entryID: 5, athleteID: 105, teamID: 1005, name: "选手E", value: 12.4},
{entryID: 6, athleteID: 106, teamID: 1006, name: "选手F", value: 13.1},
})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)},
Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
appendScore("individual", es, teams, nil)
}
// 形态 2 · 纯团体:按单位录的团体手工名次(含并列与顺延录入)。
{
teams := testTeams(1001, 1002, 1003, 1004, 1005)
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(11)},
Profiles: map[int64]ScoringProfile{11: teamTop6()},
TeamRanks: []TeamManualRank{
{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}, {TeamID: 1003, Rank: 2},
{TeamID: 1004, Rank: 4}, {TeamID: 1005, Rank: 5},
},
})
appendScore("team-manual", es, teams, nil)
}
// 形态 3 · 双计(individual_team):个人通道只记个人,单位总分按成员成绩平均排名。
{
teams := testTeams(1001, 1002, 1003)
personal := athleteResultChannel(9)
no := false
personal.CountToTeam = &no
entries, results := fixture([]participant{
{entryID: 1, athleteID: 101, teamID: 1001, name: "一司1", value: 12},
{entryID: 2, athleteID: 102, teamID: 1001, name: "一司2", value: 14},
{entryID: 3, athleteID: 103, teamID: 1002, name: "二司1", value: 13},
{entryID: 4, athleteID: 104, teamID: 1002, name: "二司2", value: 15},
{entryID: 5, athleteID: 105, teamID: 1003, name: "三司1", value: 16},
{entryID: 6, athleteID: 106, teamID: 1003, name: "三司2", value: 16},
})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{personal, teamAggregateChannel(11)},
Profiles: map[int64]ScoringProfile{9: individualTop6(), 11: teamTop6()},
Entries: entries, Results: results,
})
appendScore("individual-team", es, teams, nil)
}
// 形态 4 · 团体报名项双通道(团体报名项 athlete_id=0 + 个人成绩通道 + 团体手工通道)。
{
teams := testTeams(1001, 1002, 1003)
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, value: 90, rank: 1},
{entryID: 2, teamID: 1002, value: 80, rank: 2},
{entryID: 3, teamID: 1003, value: 70, rank: 3},
})
in := EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9), teamManualChannel(11)},
Profiles: map[int64]ScoringProfile{9: individualTop6(), 11: teamTop6()},
Entries: entries, Results: results,
}
// 4a:名次只来自团体报名项自己的 results.rank。
appendScore("team-entry", mustScore(t, in), teams, nil)
// 4b:名次来自「按单位录入的团体名次」(覆盖值优先于报名项自己的名次)。
withOverride := in
withOverride.TeamRanks = []TeamManualRank{{TeamID: 1001, Rank: 2}, {TeamID: 1002, Rank: 1}}
appendScore("team-entry-override", mustScore(t, withOverride), teams, nil)
// 4c:团体报名项没有名次,名次只有按单位录入的覆盖值(合成名次那一支)。
blank, blankResults := fixture([]participant{
{entryID: 1, teamID: 1001, value: 90},
{entryID: 2, teamID: 1002, value: 80},
{entryID: 3, teamID: 1003, value: 70},
})
onlyOverride := in
onlyOverride.Entries = blank
onlyOverride.Results = blankResults
onlyOverride.TeamRanks = []TeamManualRank{{TeamID: 1001, Rank: 3}, {TeamID: 1002, Rank: 1}, {TeamID: 1003, Rank: 2}}
appendScore("team-entry-override-only", mustScore(t, onlyOverride), teams, nil)
}
// 形态 5 · 无并列的团体聚合(avg / all 两种分母口径),补上聚合通道的另一半覆盖面。
{
teams := testTeams(1001, 1002)
for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} {
ev := teamEvent(1)
ev.AvgMode = mode
entries, results := fixture([]participant{
{entryID: 1, athleteID: 101, teamID: 1001, name: "一司1", value: 10},
{entryID: 2, athleteID: 102, teamID: 1001, name: "一司2", value: 14},
{entryID: 3, athleteID: 103, teamID: 1002, name: "二司1", value: 12, noValue: true},
{entryID: 4, athleteID: 104, teamID: 1002, name: "二司2", value: 16},
})
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(11)},
Profiles: map[int64]ScoringProfile{11: teamTop6()},
Entries: entries, Results: results,
})
appendScore("aggregate-"+string(mode), es, teams, nil)
}
}
return strings.Join(lines, "\n") + "\n"
}
// formatShares 把归属份额打印成 `单位ID:权重` 列表。
// 刻意只取这两个字段:签名要盯的是「分给了谁」,不是结构体里后来又加了什么开关。
func formatShares(shares []TeamShare) string {
if len(shares) == 0 {
return "-"
}
parts := make([]string, 0, len(shares))
for _, s := range shares {
parts = append(parts, fmt.Sprintf("%d:%v", s.TeamID, s.Weight))
}
return "[" + strings.Join(parts, ",") + "]"
}
+114 -8
View File
@@ -39,8 +39,11 @@ type EventScore struct {
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
// Points 尚未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
// Shares 是该名次的归属单位;当前只有「整体归主单位」一种口径(联队未实现),
// 保留份额结构是因为导出 / 实时快照等消费方按份额遍历。
//
// Shares 是该名次的归属单位份额:报名项配了联队就是多条(如 A、B 各半),否则是主单位独占
// 一条;积分按 Weight 拆、奖牌按 CountsMedal 整枚各记(见 TeamShare)。名次本身只有一条,
// 分数与奖牌的下发(拆成几份、记给谁)全部由 Shares 决定——流水侧在 ledgerFromRanked 拆成多行,
// 奖牌侧在 standings.teamMedals 逐份额记牌。
type RankedResult struct {
EntryID int64
ResultID int64
@@ -295,6 +298,13 @@ func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidat
if taken[or.teamID] {
continue
}
// 联队:该主单位在本组别报了联队(团体行 + 多单位份额)时,这条按单位录的名次就是
// 联队的名次,份额取自那条报名——否则 A+B 拿到的名次会整份记在 A 头上,B 一分没有。
// 没有联队报名(老数据、普通报名)时仍走下面的合成报名项,名次主体照样是单位。
if joint, ok := jointTeamEntry(in, or.teamID); ok {
out = append(out, candidate{entry: joint, rankIn: or.rank})
continue
}
// 合成报名项:ID=0 表示「不对应任何一条报名项」,团体名次的主体是单位。
out = append(out, candidate{
entry: Entry{TeamID: or.teamID},
@@ -304,6 +314,48 @@ func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidat
return out
}
// jointTeamEntry 找「主单位 teamID 在本组别的联队报名」:团体行(athlete_id=0)+ 配了多个单位的份额。
//
// 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上,
// 所以录名次时得回头找这条报名。找不到(含只配了一个单位的普通报名)就返回 false,
// 调用方退回「主单位独占 100%」的合成报名项。
//
// 为什么把「只有一个单位」也排除:那样与独占 100% 等价,却会把名次的身份从单位(subject_key
// `t:<id>`)换成报名项(`e:<id>`),channel_ranks 的既有行会跟着变——老数据必须逐字节不变。
//
// 同一个组别同一主单位最多只有一条团体行(entries 的唯一索引兜着),真出现多条且份额不一致时
// 退回独占:宁可少拆,也不能随便挑一条把分拆错。
func jointTeamEntry(in EventScoreInput, teamID int64) (Entry, bool) {
var found Entry
ok := false
for _, e := range in.Entries {
if e.AthleteID != 0 || e.TeamID != teamID || !entryMatches(in, e) || !e.Status.rankable() {
continue
}
if len(e.TeamShares) < 2 {
continue
}
if ok && !sameShares(found.TeamShares, e.TeamShares) {
return Entry{}, false
}
found, ok = e, true
}
return found, ok
}
// sameShares 判断两组份额是否一致(单位集合与各自权重、奖牌开关都比)。
func sameShares(a, b []TeamShare) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].TeamID != b[i].TeamID || !nearlyEqual(a[i].Weight, b[i].Weight) || a[i].CountsMedal() != b[i].CountsMedal() {
return false
}
}
return true
}
func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
cands, err := collectCandidates(in, ch)
if err != nil {
@@ -449,7 +501,7 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
AthleteID: c.entry.AthleteID,
AthleteName: c.entry.AthleteName,
TeamID: c.entry.primaryTeamID(),
Shares: wholeShare(c.entry.primaryTeamID()),
Shares: ResolveShares(c.entry.TeamShares, c.entry.primaryTeamID()),
Rank: rank,
Points: points,
Status: c.result.Status,
@@ -573,6 +625,10 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
Rank: g.startRank,
Points: pts,
}
// 聚合名次不对应任何一条报名项(EntryID=0,主体是单位或运动员),读取侧
// (channel_ranks → buildRanked)没有报名项可以回查份额,只能按主体单位独占;
// 引擎必须同口径,否则「流水按份额拆了、奖牌榜还按单位独占」,两处对不上。
// 因此联队 + 聚合通道不拆份额(已记进 devdocs/19 的未决项:聚合按主单位分桶)。
if ch.Scope == ScopeTeam {
rr.TeamID = c.subject
rr.Shares = wholeShare(c.subject)
@@ -649,9 +705,9 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
if sourceID == 0 {
sourceID = ch.ID
}
for _, s := range rr.Shares {
pts := rr.Points * s.Weight
if pts == 0 {
split := splitSharePoints(rr.Points, rr.Shares)
for i, s := range rr.Shares {
if split[i] == 0 {
continue
}
out = append(out, LedgerEntry{
@@ -664,7 +720,7 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
ChannelID: ch.ID,
SourceType: SourceResult,
SourceID: sourceID,
Points: pts,
Points: split[i],
CountToTeam: ch.countToTeam(),
})
}
@@ -688,10 +744,60 @@ func subjectKeyOf(rr RankedResult) string {
}
}
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
// ResolveShares 把「报名项配的份额」解析成最终归属份额:配了(联队)就用配置,
// 没配就是主单位独占 100%,与升级前逐字段一致。读侧(channel_ranks → RankedResult)
// 与引擎共用它,避免两边各写一份「没有份额时怎么办」。
func ResolveShares(configured []TeamShare, teamID int64) []TeamShare {
if len(configured) > 0 {
return configured
}
return wholeShare(teamID)
}
// wholeShare 构造「主单位独占 100%」的归属份额:没有联队配置时的唯一口径。
func wholeShare(teamID int64) []TeamShare {
if teamID == 0 {
return nil
}
return []TeamShare{{TeamID: teamID, Weight: 1}}
}
// splitSharePoints 把一条成绩的积分按份额拆给各成员单位,返回值与 shares 一一对应。
//
// 权重是相对值(默认各 1 = 均分),先按权重之和归一:联队的两个单位各 1 份时总分照旧,
// 2:1 时也拆成两单位之和等于原来那一份分——**份额只改「分记在谁头上」,不改变总分**。
// 没有配份额(老数据、普通报名)时是单条独占:total·1/1,结果与升级前逐位相同。
//
// 最后一份取「总分减去前面各份」的余数,而不是再算一次乘法:2:1 这种除不尽的权重
// 各份直接算会剩下 1e-15 级零头,榜单上就会印出 9.999999999999998 这种数。
//
// 权重 <= 0 按 1 处理:库里有 CHECK(points_share > 0) 兜着,这里是引擎侧的兜底,
// 与其它地方的「零值按 1」口径(multiplier / effectiveWeight)一致。
func splitSharePoints(total float64, shares []TeamShare) []float64 {
out := make([]float64, len(shares))
if len(shares) == 0 {
return out
}
sum := 0.0
for _, s := range shares {
sum += shareWeight(s)
}
acc := 0.0
for i, s := range shares {
if i == len(shares)-1 {
out[i] = total - acc
break
}
out[i] = total * shareWeight(s) / sum
acc += out[i]
}
return out
}
// shareWeight 取份额的积分权重,零值与负值按 1 处理(见 splitSharePoints)。
func shareWeight(s TeamShare) float64 {
if s.Weight <= 0 {
return 1
}
return s.Weight
}
+6 -2
View File
@@ -328,8 +328,9 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
return out
}
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
// 将来若支持联队拆分,每个成员单位都会各记一枚。
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:每个份额各记**一枚整牌**,
// 联队拿到的金牌因此两个成员单位各记 1 枚(不是各 0.5 枚,口径见 TeamShare.Medal)。
// 份额里的 Medal 显式关掉时该单位不记这枚牌(积分份额 Weight 不参与奖牌计算)。
//
// 两层开关(devdocs 17 §4.5):
// - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜;
@@ -347,6 +348,9 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
continue
}
for _, s := range rr.Shares {
if !s.CountsMedal() {
continue
}
t := out[s.TeamID]
switch medal {
case 1:
+23 -3
View File
@@ -288,14 +288,31 @@ type ScoringProfile struct {
Rules []ScoringRule `json:"rules"`
}
// TeamShare 是一条成绩的归属单位份额。联队拆分未实现,引擎只产出「主单位独占 100%」;
// 保留结构是因为导出 / 实时快照等消费方按份额遍历 RankedResult.Shares。
// TeamShare 是一条成绩的归属单位份额:两个单位合并成联队参赛时,一条成绩由多个单位共享。
//
// 积分与奖牌是两套口径,必须分开表达(devdocs/19 第三节的拍板结果):
// - Weight 只决定**积分**怎么拆,是相对权重(按权重之和归一,1+1 就是各一半;缺省值 1);
// 奇数分允许出现 0.5 这样的小数,榜单上就写 7.5;
// - Medal 只决定这枚**奖牌**是否整枚记给本单位。联队拿到的金牌两个单位各记 1 枚,
// 不按 Weight 拆成半枚——奖牌榜数的是整数枚,跟着权重拆会立刻错。
//
// 普通报名(没有联队)与联队用同一个结构表达:一条 {主单位, Weight:1},也就是「主单位独占 100%」,
// 与升级前 wholeShare 的产出逐字段一致。
type TeamShare struct {
TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"`
// Medal 为 nil 表示按缺省处理(整枚记给本单位),与通道开关 CountToTeam 的 *bool 写法同源:
// 零值必须落在「老数据的行为」那一侧,否则既有调用方构造的 {TeamID, Weight} 会突然丢掉奖牌。
Medal *bool `json:"medal,omitempty"`
}
// Entry 报名项:某单位派某人/某队参加某小项。
// CountsMedal 判断这枚奖牌是否整枚记给本单位;未配置时按整枚处理。
func (s TeamShare) CountsMedal() bool {
return s.Medal == nil || *s.Medal
}
// Entry 报名项:某单位派某人/某队参加某小项。TeamID 是**主单位**(联队时也保留它,
// 报名项的唯一键、榜单兜底、导入去重都还认这一个),联队的其余成员单位在 TeamShares 里。
type Entry struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
@@ -308,6 +325,9 @@ type Entry struct {
Seed string `json:"seed,omitempty"`
Status EntryStatus `json:"status"`
Remark string `json:"remark,omitempty"`
// TeamShares 是这条报名的归属份额配置:空 = 主单位独占 100%(老数据、普通报名),
// 多条 = 联队。引擎只读不改,取分的唯一入口是 ResolveShares。
TeamShares []TeamShare `json:"teamShares,omitempty"`
}
// Result 成绩;一条报名项一条成绩。
+15 -4
View File
@@ -156,7 +156,11 @@ func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook,
if err != nil {
return AthleteDetailWorkbook{}, err
}
rows, err := loadAthleteDetailRows(db)
joints, err := loadJointTeams(db)
if err != nil {
return AthleteDetailWorkbook{}, err
}
rows, err := loadAthleteDetailRows(db, joints)
if err != nil {
return AthleteDetailWorkbook{}, err
}
@@ -208,7 +212,7 @@ const athleteDetailRankExpr = `COALESCE((
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
LIMIT 1), COALESCE(r.rank, 0))`
func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
func loadAthleteDetailRows(db *sql.DB, joints map[int64]jointTeam) ([]AthleteDetailRow, error) {
// 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案;
// 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。
rows, err := db.Query(`
@@ -217,7 +221,7 @@ func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
` + athleteDetailRankExpr + `, COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
l.points, l.source_type,
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, ca.sort_order, 0),
COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id
COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id, COALESCE(l.entry_id, 0)
FROM score_ledger l
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN events e ON e.id = l.event_id
@@ -247,16 +251,23 @@ func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
source string
resultText string
unit string
entryID int64
)
if err := rows.Scan(&row.teamID, &row.AthleteName,
&row.SportName, &row.EventName, &row.Category,
&row.Rank, &resultText, &unit, &row.Points, &source,
&row.sportSort, &row.eventSort, &row.catSort,
&row.eventID, &row.categoryID, &row.ledgerID); err != nil {
&row.eventID, &row.categoryID, &row.ledgerID, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描个人得分明细: %w", err)
}
row.ResultText = displayResult(resultText, unit)
row.Source = sourceLabel(source)
// 联队说明挂在「来源」列:这张表没有备注列,而「来源」写的正是这一分是从哪来的
//(成绩 / 破纪录加分 / 人工调整)。联队的一条成绩会给每个成员单位各留一行流水,
// 不写这一句,读表的人只会看到同一个人在同一小项出现了两次、每次半份分。
if joint, ok := joints[entryID]; ok {
row.Source += "(" + joint.shareNote() + ")"
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
+33 -3
View File
@@ -123,13 +123,18 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
if err != nil {
return EntriesWorkbook{}, err
}
// 联队配置:导出名单里的单位编码回写成导入表的 A+B 写法,单位名写全成员,备注里写清份额。
joints, err := loadJointTeams(db)
if err != nil {
return EntriesWorkbook{}, err
}
query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(t.name, ''),
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, '')
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
@@ -145,7 +150,6 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
}{
{"en.event_id", filter.EventID},
{"en.category_id", filter.CategoryID},
{"en.team_id", filter.TeamID},
{"en.heat", filter.Heat},
} {
if f.value != 0 {
@@ -153,6 +157,24 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
args = append(args, f.value)
}
}
// 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个),
// 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。
// 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。
// 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。
if filter.TeamID != 0 {
teamCond := "en.team_id = ?"
args = append(args, filter.TeamID)
ready, err := entrySharesReady(db)
if err != nil {
return EntriesWorkbook{}, err
}
if ready {
teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s
WHERE s.entry_id = en.id AND s.team_id = ?)`
args = append(args, filter.TeamID)
}
conds = append(conds, "("+teamCond+")")
}
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
@@ -176,10 +198,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
heat sql.NullInt64
age sql.NullInt64
athleteName string
entryID int64
)
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
&row.Gender, &age, &row.Status, &row.Remark); err != nil {
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
}
row.Name = participantName(athleteName, memberJSON)
@@ -192,6 +215,13 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
row.Age = ""
}
row.Status = entryExportStatusLabel(row.Status)
// 联队:单位编码回写成 A+B(这份名单能直接拿去重导),单位名写全成员,
// 备注里写清这条分拆给了哪几个单位、各占多少。报名名单没有积分列,只写份额比例。
if joint, ok := joints[entryID]; ok {
row.TeamCode = joint.Code()
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareRatioNote())
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
+260
View File
@@ -0,0 +1,260 @@
package export
import (
"database/sql"
"fmt"
"math"
"strconv"
"strings"
)
// 本文件是「联队 + 份额分成」(devdocs/19)在导出侧的读法。
//
// 一条报名配了多个成员单位时(现场有两个单位因人数不足合并参赛),导出件要做两件事:
// - 把联队的名字写出来,**成员单位名写全**,形如「第一分公司+第二分公司联队」;
// - 讲清这条分拆给了哪几个单位、各多少(份额比例 + 各单位实得积分)。
//
// 两个口径必须分开(已拍板,见 domain.TeamShare):积分按份额拆(允许 0.5 分),
// 奖牌**每个成员单位各记一枚整枚**。后者会让「各单位的奖牌数加起来」大于实际颁发的奖牌数,
// 那是定稿口径、不是 bug,所以联队奖牌那一格自己要把「各单位各记 1 枚」写出来,
// 含联队的榜单类导出件另在副标题里写一句口径说明(见 jointMedalNote)。
// jointMedalNote 是含联队的榜单类导出件(总表 / 单位奖牌明细)副标题上的口径说明。
// 奖牌数之和会大于实际颁发数是定稿口径(每个成员单位各记一整枚),
// 不说这一句,拿导出件对账的人会以为多印了奖牌。
const jointMedalNote = "注:本赛事含联队,奖牌按「每个成员单位各记 1 枚」统计,各单位奖牌数之和会大于实际颁发数"
// jointMember 是联队里的一个成员单位:展示字段 + 份额 + 本单位实得积分。
type jointMember struct {
TeamID int64
Code string
// Name 是单位全称(缺全称时退简称)——现场看的就是这个,不能用编码凑合。
Name string
Weight float64
// CountsMedal 为 false 时这枚奖牌不记给本单位(缺省 true = 整枚各记)。
CountsMedal bool
// Points 是本单位从这条成绩实得的积分(按流水统计:引擎已按份额拆好;没出分前是 0)。
Points float64
// Primary 标记这是报名的主单位(entries.team_id):显示名与导出的编码都以它打头。
Primary bool
}
// jointTeam 是一条报名配的联队:成员单位按份额顺序(主单位排第一),至少两个。
type jointTeam struct {
members []jointMember
}
// Name 是联队的显示名:成员单位名写全,用 + 连起来再加「联队」——「第一分公司+第二分公司联队」。
// 写法与导入表 team_code 的 `A+B` 同构,现场拿着一份表能一眼对上另一份。
func (t jointTeam) Name() string {
names := make([]string, 0, len(t.members))
for _, m := range t.members {
names = append(names, m.Name)
}
return strings.Join(names, "+") + "联队"
}
// Code 是联队的单位编码:`T1+T2`,与导入表的 team_code 写法逐字一致,
// 于是导出的报名名单可以直接拿去重导(round-trip),不必手工改回单单位。
func (t jointTeam) Code() string {
codes := make([]string, 0, len(t.members))
for _, m := range t.members {
codes = append(codes, m.Code)
}
return strings.Join(codes, "+")
}
// shareRatioNote 是只讲份额的说明:「联队分拆:第一分公司 50%、第二分公司 50%」。
// 报名名单用它:那张表是报名侧的数据(号码布 / 批次 / 状态),没有积分列,
// 凭空冒出「5 分」反而让人以为这里也在算分。
func (t jointTeam) shareRatioNote() string {
return "联队分拆:" + strings.Join(t.shareItems(false), "、")
}
// shareNote 是份额说明:「联队分拆:第一分公司 7.5 分(50%)、第二分公司 7.5 分(50%)」。
//
// 已经出分(成员里有非零积分)时把各单位的实得积分一并写出来——「各多少」问的就是它,
// 而导出件上的「积分」列只写了联队拿到的总分。还没出分时退化成只写份额比例。
func (t jointTeam) shareNote() string {
return "联队分拆:" + strings.Join(t.shareItems(true), "、")
}
// shareItems 拼各成员单位的份额描述,顺序与份额一致(主单位在前)。
func (t jointTeam) shareItems(withPoints bool) []string {
sum := 0.0
for _, m := range t.members {
sum += shareWeightOf(m.Weight)
}
if withPoints {
withPoints = false
for _, m := range t.members {
if m.Points != 0 {
withPoints = true
}
}
}
items := make([]string, 0, len(t.members))
for _, m := range t.members {
percent := formatPercent(shareWeightOf(m.Weight), sum)
if withPoints {
items = append(items, fmt.Sprintf("%s %s 分(%s)", m.Name, formatPoints(m.Points), percent))
continue
}
items = append(items, m.Name+" "+percent)
}
return items
}
// medalCell 把一枚联队奖牌写全:「金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)」。
//
// 括注必须留在奖牌这一格:奖牌明细里非联队行只有「金牌」两个字,读者把各单位的奖牌数加起来
// 会比实际颁发的多(口径如此),这一句就是给那个数字的解释。有成员被显式关掉记牌时改写成
// 「记给 甲、乙」——那种写法现在只有改份额的接口做得出来,但照样不能让人猜。
func (t jointTeam) medalCell(medal string) string {
all := make([]string, 0, len(t.members))
counting := make([]string, 0, len(t.members))
for _, m := range t.members {
all = append(all, m.Name)
if m.CountsMedal {
counting = append(counting, m.Name)
}
}
if len(counting) == len(all) {
return fmt.Sprintf("%s(联队 %s,各单位各记 1 枚)", medal, strings.Join(all, "+"))
}
return fmt.Sprintf("%s(联队 %s,记给 %s)", medal, strings.Join(all, "+"), strings.Join(counting, "、"))
}
// shareWeightOf 与引擎的 shareWeight 同一口径:0 与负数按 1 处理(份额是相对权重,不填即均分)。
func shareWeightOf(weight float64) float64 {
if weight <= 0 {
return 1
}
return weight
}
// formatPercent 把份额写成占比(weight / Σweight):1+1 → 50%、2:1 → 66.7% / 33.3%。
// 保留一位小数并去掉多余的 0:现场看的是一眼能懂的比例,不是浮点数的原貌。
func formatPercent(weight, sum float64) string {
if sum <= 0 {
sum = 1
}
return strconv.FormatFloat(math.Round(weight/sum*1000)/10, 'f', -1, 64) + "%"
}
// formatPoints 把积分写成文本:整数不带小数点,小数最多两位(引擎按份额拆出的 7.5 照实写)。
func formatPoints(v float64) string {
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}
// appendNote 把一句说明补进一格文本:原有内容(报名备注、修订痕迹)排在前面,用分号隔开。
func appendNote(existing, note string) string {
if strings.TrimSpace(note) == "" {
return existing
}
if strings.TrimSpace(existing) == "" {
return note
}
return existing + ";" + note
}
// entrySharesReady 判断份额表在不在。
//
// 读路径要能读「还没跑到 0024 的库」:没有那张表就没有任何联队配置,显示照旧(主单位独占)。
// 与 httpapi.entryTeamSharesReady 同一判据,只是这边没有 context 参数。
func entrySharesReady(db *sql.DB) (bool, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return false, fmt.Errorf("export: 查询报名份额表: %w", err)
}
return n != 0, nil
}
// loadJointTeams 读全库的联队配置,按报名 id 归组;只有一条份额的报名不算联队,不进结果。
//
// 「只有一条」这一档必须排掉:迁移会把每条老报名回填成一条「主单位独占 100%」,
// 导入也给普通报名写一条,把它们当成联队会让**所有**导出件的单位名列变成「XX联队」——
// 那是老数据逐字节不变这条硬门槛要挡的事。
//
// 各单位的实得积分一并读出来(分数按份额拆、末位吞余数,只在库里才准),
// 用它拼「联队分拆:A 7.5 分、B 7.5 分」。表不在时返回空:没有份额表就没有联队配置。
func loadJointTeams(db *sql.DB) (map[int64]jointTeam, error) {
ready, err := entrySharesReady(db)
if err != nil {
return nil, err
}
if !ready {
return nil, nil
}
rows, err := db.Query(`
SELECT s.entry_id, s.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
s.points_share, s.medal_share, e.team_id,
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = s.entry_id AND l.team_id = s.team_id AND l.voided = 0), 0)
FROM entry_team_shares s
JOIN teams t ON t.id = s.team_id
JOIN entries e ON e.id = s.entry_id
ORDER BY s.entry_id, s.sort_order, s.team_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取联队份额: %w", err)
}
defer func() { _ = rows.Close() }()
grouped := make(map[int64][]jointMember)
for rows.Next() {
var (
entryID int64
m jointMember
name string
short string
medal int
primary int64
)
if err := rows.Scan(&entryID, &m.TeamID, &m.Code, &name, &short,
&m.Weight, &medal, &primary, &m.Points); err != nil {
return nil, fmt.Errorf("export: 扫描联队份额: %w", err)
}
m.Name = displayName(name, short)
if m.Name == "" {
m.Name = m.Code
}
m.CountsMedal = medal != 0
m.Primary = primary == m.TeamID
grouped[entryID] = append(grouped[entryID], m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历联队份额: %w", err)
}
out := make(map[int64]jointTeam, len(grouped))
for entryID, members := range grouped {
if len(members) < 2 {
continue
}
out[entryID] = jointTeam{members: primaryFirst(members)}
}
return out, nil
}
// primaryFirst 把主单位挪到成员列表最前面,其余保持份额表的顺序。
//
// 显示名与导出的 `A+B` 都以主单位打头(口径:加号左边那个是主单位),而份额表里主单位
// 不一定排第一——改份额的接口只要求主单位在成员里,没要求它排头。不归位的话,
// 一份导出件里的 `B+A` 重导回去会把主单位换成 B,报名身份(唯一键)跟着变。
func primaryFirst(members []jointMember) []jointMember {
out := make([]jointMember, 0, len(members))
for _, m := range members {
if m.Primary {
out = append(out, m)
}
}
for _, m := range members {
if !m.Primary {
out = append(out, m)
}
}
return out
}
@@ -0,0 +1,408 @@
package export
import (
"database/sql"
"path/filepath"
"strings"
"testing"
)
// 联队的导出侧(devdocs/19):一条成绩按份额拆给多个单位时,导出件必须
// 1. 把联队的名字写出来,成员单位名写全(形如「第一分公司+第二分公司联队」);
// 2. 看得出这条分拆给了哪几个单位、各多少(份额 + 各单位实得积分)。
// 本文件逐份导出件钉住这两条;另一条硬门槛(没有联队的老数据一字不变)由
// TestSingleUnitExportsHaveNoJointTeamText 与既有的各份导出用例共同守住。
// jointTeamMemberNames 是联队里的两个成员单位(与 seedEvent 的单位一致)。
const (
jointNameT1 = "第一分公司"
jointNameT2 = "第二分公司"
// jointTeamDisplay 是联队的显示名:成员单位名写全,中间用 + 连、末尾标「联队」。
jointTeamDisplay = jointNameT1 + "+" + jointNameT2 + "联队"
// jointShareNote 是均分两份时的份额说明(两条流水的实得积分都是 5 分)。
jointShareNote = "联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)"
// jointShareOnlyNote 是没有流水时(报名阶段)的份额说明:只讲份额,不讲分。
jointShareOnlyNote = "联队分拆:第一分公司 50%、第二分公司 50%"
)
// seedJointTeamEntry 把 seedEvent 的第 1 名改成联队(T1+T2)拿的:份额均分、奖牌整枚各记,
// 流水也按份额拆成两行。现场这两样都是引擎算出来的,导出测试直接造库(与 seedChannelRanks 同一套做法)。
func seedJointTeamEntry(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 2, 1, 1, 1)`,
// 引擎把这一条成绩的 10 分拆成两行流水(份额之和归一,两名单位各一半)。
`UPDATE score_ledger SET points = 5 WHERE entry_id = 1`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (2, 1, 1, 1, 1, 'result', 1, 5)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造联队数据 %q: %v", s, err)
}
}
}
// seedSingleShareRow 给某条报名只配一条份额:迁移与导入都会这么写(普通报名是「主单位独占 100%」),
// 它**不是**联队,导出件必须与升级前一模一样。
func seedSingleShareRow(t *testing.T, db *sql.DB, entryID, teamID int64) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 0)`, entryID, teamID); err != nil {
t.Fatalf("造独占份额: %v", err)
}
}
// 分项成绩表:单位列写全联队成员,备注列写清分给了谁、各多少。
func TestJointTeamEventSheetShowsMembersAndShares(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, dst, "田径·100米跑·男子")
// 第 1 名那条是联队(列序:名次/号码/单位/运动员-队/成绩/积分/状态/备注)。
if got := cell(rows, 4, 2); got != jointTeamDisplay {
t.Errorf("联队行的单位 = %q,期望 %q", got, jointTeamDisplay)
}
if got := cell(rows, 4, 5); got != "10" {
t.Errorf("联队行的积分 = %q,期望 10(联队拿到的总分)", got)
}
if got := cell(rows, 4, 7); !strings.Contains(got, jointShareNote) {
t.Errorf("联队行的备注 = %q,期望含 %q", got, jointShareNote)
}
// 第 2 名那条不是联队:单位与备注都不该多出任何东西。
if got := cell(rows, 5, 2); got != jointNameT2 {
t.Errorf("非联队行的单位 = %q,期望 %q", got, jointNameT2)
}
if got := cell(rows, 5, 7); got != "" {
t.Errorf("非联队行的备注应为空,得到 %q", got)
}
}
// 报名名单:单位编码回写成导入时的 A+B 写法(导出件可直接拿去重导),单位名写全联队成员。
func TestJointTeamEntriesSheetWritesJointTeamCode(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
// 列序:小项编码/小项名/组别/单位编码/单位名/姓名/证件号/号码布/批次/性别/年龄/状态/备注。
if got := cell(rows, 4, 3); got != "T1+T2" {
t.Errorf("联队行的单位编码 = %q,期望 T1+T2(与导入表的写法一致)", got)
}
if got := cell(rows, 4, 4); got != jointTeamDisplay {
t.Errorf("联队行的单位名 = %q,期望 %q", got, jointTeamDisplay)
}
if got := cell(rows, 4, 12); !strings.Contains(got, jointShareOnlyNote) {
t.Errorf("联队行的备注 = %q,期望含 %q", got, jointShareOnlyNote)
}
if got := cell(rows, 5, 3); got != "T2" {
t.Errorf("非联队行的单位编码 = %q,期望 T2", got)
}
}
// 单位积分明细:某单位拿到的这一条来自联队,备注要说清分给了哪几个单位、各多少。
func TestJointTeamTeamDetailNotesShare(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出单位积分明细: %v", err)
}
rows := readSheet(t, dst, "第一分公司积分明细")
if got := cell(rows, 4, 8); got != "5" {
t.Errorf("联队份额行的积分 = %q,期望 5(本单位那一半)", got)
}
if got := cell(rows, 4, 9); !strings.Contains(got, jointShareNote) {
t.Errorf("联队份额行的备注 = %q,期望含 %q", got, jointShareNote)
}
// 总明细(第一张总表 + 每单位一张分表)与单表同口径,逐格写的是同一句话。
allDst := filepath.Join(t.TempDir(), "单位总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, allDst, Options{}); err != nil {
t.Fatalf("导出所有单位积分明细: %v", err)
}
master := readSheet(t, allDst, "积分总明细")
found := false
for r := range master {
if cell(master, r, 0) == jointNameT1 && strings.Contains(cell(master, r, 10), jointShareNote) {
found = true
}
}
if !found {
t.Errorf("总明细里第一分公司的联队份额行没有份额说明:%v", master)
}
}
// 个人积分总明细:没有备注列,联队说明写在「来源」列(该行得分的来处)。
func TestJointTeamAthleteDetailNotesShare(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出个人得分明细: %v", err)
}
rows := readSheet(t, dst, "第一分公司个人得分明细")
// 列序:运动员/大项/小项/组别/名次/成绩/得分/来源。
if got := cell(rows, 4, 6); got != "5" {
t.Errorf("联队份额行的得分 = %q,期望 5", got)
}
if got := cell(rows, 4, 7); !strings.Contains(got, jointShareNote) {
t.Errorf("联队份额行的来源 = %q,期望含 %q", got, jointShareNote)
}
}
// 单位奖牌明细:联队金牌两个单位各记一枚,导出件要在那一行就说清「各单位各记 1 枚」,
// 否则把各单位的奖牌数加起来会比实际颁发的多(口径 3,不是 bug)。
func TestJointTeamMedalDetailShowsEveryUnit(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
want := "金牌(联队 " + jointNameT1 + "+" + jointNameT2 + ",各单位各记 1 枚)"
master := readSheet(t, dst, "单位奖牌明细")
gold := make([]string, 0, 2)
for r := range master {
if strings.HasPrefix(cell(master, r, 5), "金牌") {
gold = append(gold, cell(master, r, 5))
}
}
if len(gold) != 2 {
t.Fatalf("联队金牌应在总表出两行(两个单位各一行),得到 %d 行:%v", len(gold), master)
}
for i, got := range gold {
if got != want {
t.Errorf("总表第 %d 行联队奖牌 = %q,期望 %q", i+1, got, want)
}
}
// 非联队的银牌那一行一字不动。
plain := false
for r := range master {
if cell(master, r, 5) == "银牌" {
plain = true
}
}
if !plain {
t.Errorf("普通奖牌行应保持「银牌」原文:%v", master)
}
// 分表与总表同口径:第一分公司那一张表里的金牌也是同一句话。
team := readSheet(t, dst, "第一分公司奖牌明细")
if got := cell(team, 4, 4); got != want {
t.Errorf("分表联队奖牌 = %q,期望 %q", got, want)
}
// 分表小计照旧数「本单位拿到几枚」——联队口径下它每个单位各算 1 枚。
if got := cell(team, 5, 4); got != "金 1 / 银 0 / 铜 0" {
t.Errorf("分表小计 = %q,期望 金 1 / 银 0 / 铜 0", got)
}
}
// 含联队的榜单类导出件要有一句口径说明:奖牌按「每个成员单位各记 1 枚」统计,
// 各单位奖牌数之和会大于实际颁发数。合计行本身不动(口径 3)。
func TestJointTeamStandingsNotesMedalRule(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "总表.xlsx")
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出总表: %v", err)
}
medal := readSheet(t, dst, "单位奖牌榜")
if got := cell(medal, 1, 0); !strings.Contains(got, "每个成员单位各记 1 枚") {
t.Errorf("单位奖牌榜的副标题 = %q,期望含奖牌口径说明", got)
}
// 口径没变:两个单位各记 1 枚金牌(总表两行都是 1,不是各 0.5)。
count := 0
for r := range medal {
if cell(medal, r, 1) == "T1" || cell(medal, r, 1) == "T2" {
if cell(medal, r, 3) != "1" {
t.Errorf("%s 金牌 = %q,期望 1(整枚各记)", cell(medal, r, 1), cell(medal, r, 3))
}
count++
}
}
if count != 2 {
t.Errorf("单位奖牌榜应有 2 个单位行,得到 %d", count)
}
}
// 球类现场的形态:名次是按单位手工录的(团体通道、没有对应成绩行),channel_ranks 的
// entry_id 指到那条联队报名(引擎的 jointTeamEntry 干的活)。奖牌明细同样要认得出联队——
// 只按成绩 id 找来源的做法在这里取不到任何展示字段(result_id 为空)。
func TestJointTeamMedalDetailFromManualTeamRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
var profileID int64
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读团体积分模板: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'team', 'manual', ?, 1)`, profileID); err != nil {
t.Fatalf("造团体通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels
WHERE event_id = 1 AND scope = 'team' AND basis = 'manual'`).Scan(&channelID); err != nil {
t.Fatalf("读团体通道: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, team_id, rank, points, status)
VALUES (1, 1, ?, 'manual', 'team', 'e:1', 1, NULL, 1, 1, 0, 'ok')`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
want := "金牌(联队 " + jointNameT1 + "+" + jointNameT2 + ",各单位各记 1 枚)"
master := readSheet(t, dst, "单位奖牌明细")
gold := 0
for r := range master {
if strings.HasPrefix(cell(master, r, 5), "金牌") {
gold++
if got := cell(master, r, 5); got != want {
t.Errorf("总表联队奖牌 = %q,期望 %q", got, want)
}
}
}
if gold != 2 {
t.Fatalf("联队金牌应给两个单位各出一行,得到 %d 行:%v", gold, master)
}
}
// 按成员单位筛报名名单:联队那条报名挂在主单位(T1)名下,但成员单位 T2 的名单里也得有它,
// 否则 T2 导出自己的名单时,会以为自己没派人上场。
func TestJointTeamEntriesSheetFilteredByMemberUnit(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{TeamID: 2}, Options{}); err != nil {
t.Fatalf("按成员单位导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
found := false
for r := range rows {
if cell(rows, r, 3) == "T1+T2" {
found = true
}
}
if !found {
t.Errorf("按成员单位 T2 导出时应能看到联队那条报名:%v", rows)
}
// 主单位筛选照旧(老行为一字不改)。
primaryDst := filepath.Join(t.TempDir(), "报名主单位.xlsx")
if err := ExportEntriesXLSX(db, primaryDst, EntriesFilter{TeamID: 1}, Options{}); err != nil {
t.Fatalf("按主单位导出报名名单: %v", err)
}
primary := readSheet(t, primaryDst, "报名名单")
if len(primary) != 5 { // 4 行版式 + 主单位自己的那一条
t.Fatalf("按主单位筛应只出 T1 的那一条,得到 %d 行:%v", len(primary), primary)
}
}
// 份额表里主单位不排第一时(改份额的接口允许这样写),导出件的名字与 A+B 仍以主单位打头:
// 否则 `B+A` 重导回去会把报名的主单位换成 B。
func TestJointTeamDisplayPutsPrimaryFirst(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 故意把成员单位排在主单位前面(sort_order 0 = T2,1 = T1)。
for _, s := range []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 2, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 1, 1, 1, 1)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造份额 %q: %v", s, err)
}
}
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
if got := cell(rows, 4, 3); got != "T1+T2" {
t.Errorf("单位编码 = %q,期望 T1+T2(主单位永远排最前)", got)
}
if got := cell(rows, 4, 4); got != jointTeamDisplay {
t.Errorf("单位名 = %q,期望 %q", got, jointTeamDisplay)
}
}
// 老数据门槛:只有一条独占份额(普通报名 / 迁移回填)时,各份导出件一个字都不能多。
func TestSingleUnitExportsHaveNoJointTeamText(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 迁移与导入都会给每条报名写一条独占份额:它不是联队。
seedSingleShareRow(t, db, 1, 1)
seedSingleShareRow(t, db, 2, 2)
seedChannelRanks(t, db)
dir := t.TempDir()
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, eventDst, "田径·100米跑·男子")
if got := cell(rows, 4, 2); got != jointNameT1 {
t.Errorf("普通报名的单位 = %q,期望 %q(联队显示名不能套到独占份额上)", got, jointNameT1)
}
if got := cell(rows, 4, 7); got != "" {
t.Errorf("普通报名的备注应为空,得到 %q", got)
}
entriesDst := filepath.Join(dir, "报名.xlsx")
if err := ExportEntriesXLSX(db, entriesDst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
erows := readSheet(t, entriesDst, "报名名单")
if got := cell(erows, 4, 3); got != "T1" {
t.Errorf("普通报名的单位编码 = %q,期望 T1", got)
}
if got := cell(erows, 4, 4); got != jointNameT1 {
t.Errorf("普通报名的单位名 = %q,期望 %q", got, jointNameT1)
}
standingsDst := filepath.Join(dir, "总表.xlsx")
if err := ExportStandingsXLSX(db, standingsDst, Options{}); err != nil {
t.Fatalf("导出总表: %v", err)
}
medal := readSheet(t, standingsDst, "单位奖牌榜")
for r := range medal {
if strings.Contains(cell(medal, r, 0), "联队") || strings.Contains(cell(medal, r, 1), "联队") {
t.Errorf("无联队时副标题不该出现联队口径说明:%v", medal[r])
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package export
import (
"testing"
)
// 导出是联队奖牌的第三条读路径(另有 REST 与 WS 快照):它同样读 channel_ranks,
// 再按报名项的份额补归属。少了这一步,导出的单位奖牌榜会把联队的金牌只记给主单位。
func TestJointTeamMedalExportCountsEveryMember(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
// 把 T1 的第 1 名改成联队(T1 + T2)拿的:份额均分、奖牌整枚各记。
for _, s := range []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 2, 1, 1, 1)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造联队份额 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
book, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读榜单工作簿: %v", err)
}
gold := make(map[int64]int, len(book.Medal))
for _, row := range book.Medal {
gold[row.TeamID] = row.Gold
}
if gold[1] != 1 {
t.Fatalf("主单位 T1 应记 1 枚金牌,得到 %d(完整:%+v)", gold[1], book.Medal)
}
if gold[2] != 1 {
t.Fatalf("成员单位 T2 也应记 1 枚金牌(不是 0.5 枚、也不是 0 枚),得到 %d(完整:%+v)", gold[2], book.Medal)
}
}
+92 -12
View File
@@ -23,6 +23,10 @@ type Meta struct {
// 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。
// 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。
GeneratedBy string
// Note 是附在副标题后面的口径说明,由 Load* 按数据内容补(当前只有「本赛事含联队」时才会写:
// 奖牌每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数)。
// 留空时副标题与改动前逐字一致——无联队的老导出件不能因为加了这个字段而变形。
Note string
}
func (m Meta) normalized() Meta {
@@ -132,6 +136,15 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
medal := domain.BuildMedalStandings(in)
athlete := domain.BuildAthleteStandings(in)
fillTeamDisplay(team, medal, athlete, allTeams)
// 含联队时在副标题上写清奖牌口径:每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数。
// 合计行本身不改(那是定稿口径),只把那个数字解释清楚。
joints, err := loadJointTeams(db)
if err != nil {
return StandingsWorkbook{}, err
}
if len(joints) > 0 {
meta.Note = jointMedalNote
}
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
@@ -330,11 +343,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
v := value.Float64
rr.ResultValue = &v
}
// 归属份额沿用「主单位独占 100%」:team_id 为空(聚合 / 团体手工名次理论上不会)时不造份额,
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMed != 0
@@ -343,9 +351,60 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
}
// 归属份额按报名项配置补(联队:一条成绩归多个单位):与 REST 的 buildRanked、
// 快照的 loadRanked 同口径。没有报名项的名次与没配份额的老数据都退回「主单位独占 100%」,
// team_id 为空时不造份额,免得给 0 号单位凭空记牌。
shares, err := loadEntryTeamShares(db)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。
// 与 HTTP 侧的 loadEntryTeamSharesQ 同一份表、同一套排序:展示与流水顺序要稳定。
//
// 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库
// 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。
func loadEntryTeamShares(db *sql.DB) (map[int64][]domain.TeamShare, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return nil, fmt.Errorf("export: 查询报名份额表: %w", err)
}
if n == 0 {
return nil, nil
}
rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share
FROM entry_team_shares
ORDER BY entry_id, sort_order, team_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名份额: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, fmt.Errorf("export: 扫描报名份额: %w", err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
@@ -372,13 +431,18 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
}
sheets := make([]EventSheet, 0, len(events))
// 联队配置读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。
joints, err := loadJointTeams(db)
if err != nil {
return EventsWorkbook{}, err
}
for _, ev := range events {
cats, err := loadCategories(db, ev.id)
if err != nil {
return EventsWorkbook{}, err
}
if len(cats) == 0 {
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts)
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts, joints)
if err != nil {
return EventsWorkbook{}, err
}
@@ -386,7 +450,7 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
continue
}
for _, cat := range cats {
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts)
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts, joints)
if err != nil {
return EventsWorkbook{}, err
}
@@ -465,7 +529,7 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
@@ -484,7 +548,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''),
r.result_value IS NOT NULL,
COALESCE(gs.required, 0), COALESCE(gs.published, 0)
COALESCE(gs.required, 0), COALESCE(gs.published, 0), en.id
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
@@ -535,16 +599,23 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
resultNote string
hasValue int
required, published int
entryID int64
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote, &hasValue, &required, &published); err != nil {
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote)
// 联队:单位列写全成员单位(「第一分公司+第二分公司联队」),备注里写清分给了谁、各多少。
// 普通报名(含迁移回填的单条独占份额)不走这里,单位名与备注与改动前逐字一致。
if joint, ok := joints[entryID]; ok {
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareNote())
}
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
row.NoResult = !resultState.Valid
@@ -585,7 +656,11 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID)
joints, err := loadJointTeams(db)
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID, joints)
if err != nil {
return TeamDetailWorkbook{}, err
}
@@ -638,7 +713,7 @@ const teamDetailRankExpr = `COALESCE((
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
LIMIT 1), COALESCE(r.rank, 0))`
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([]TeamDetailRow, error) {
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
// 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。
@@ -691,6 +766,11 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
// 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
// 这条流水来自联队时说明份额:本单位拿到的是拆给它的那一份(积分列已经是拆后的值),
// 备注里再把「分给了哪几个单位、各多少」一次写全,核对时不必回头查另一张表。
if joint, ok := joints[entryID]; ok {
r.Remark = appendNote(r.Remark, joint.shareNote())
}
r.individual = athleteID != 0
keys = append(keys, revisions.KeyFor(source, eventID, entryID))
out = append(out, r)
+41 -9
View File
@@ -32,6 +32,10 @@ type MedalDetailRow struct {
Medal string
Participant string
// jointNote 是联队奖牌的展示值(非空时覆盖 Medal 列):「金牌(联队 A+B,各单位各记 1 枚)」。
// 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。
jointNote string
// 排序键,只在本包内用,不导出。
sportSort int
eventSort int
@@ -59,7 +63,9 @@ var medalDetailColumns = []column{
{"小项", 18, kindText},
{"组别", 14, kindText},
{"名次", 8, kindCenter},
{"奖牌", 8, kindCenter},
// 奖牌列不是窄的居中列:联队奖牌要把「各单位各记 1 枚」写在奖牌格子里(口径 3),
// 窄列 + 居中 + 不换行会被右边非空的「运动员/队」裁掉,那句话就白写了。
{"奖牌", 26, kindText},
// 与分项成绩表的「运动员/队」同宽:奖牌明细同样会印接力队名单。
{"运动员/队", 24, kindText},
}
@@ -153,7 +159,11 @@ func medalRow(r MedalDetailRow) []any {
if r.Rank > 0 {
rank = strconv.Itoa(r.Rank)
}
return []any{r.SportName, r.EventName, r.Category, rank, r.Medal, r.Participant}
medal := any(r.Medal)
if r.jointNote != "" {
medal = r.jointNote
}
return []any{r.SportName, r.EventName, r.Category, rank, medal, r.Participant}
}
// LoadMedalDetailWorkbook 读取全部奖牌来源。
@@ -168,6 +178,14 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
if err != nil {
return MedalDetailWorkbook{}, err
}
// 联队配置:一条成绩归多个单位时,每个成员单位各记一枚整牌,奖牌那一格要写清这件事。
joints, err := loadJointTeams(db)
if err != nil {
return MedalDetailWorkbook{}, err
}
if len(joints) > 0 {
meta.Note = jointMedalNote
}
// 名次输入与榜单同源:loadRanked 只取已发布、有效、计分小项的名次。
ranked, err := loadRanked(db)
if err != nil {
@@ -197,7 +215,7 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
sheets := make([]MedalDetailSheet, 0, len(teams))
for _, t := range teams {
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx)
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints)
if len(rows) == 0 {
continue
}
@@ -208,10 +226,13 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
// medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。
// 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。
// entryID 是这条名次对应的报名项:联队份额挂在报名的,联队奖牌的那句说明只能靠它查到
// (球类现场的名次正是手工按单位录的,此时 resultID 为空,只有 entryID 认得出来)。
type medalHit struct {
eventID int64
categoryID int64
resultID int64
entryID int64
rank int
medal string
}
@@ -235,15 +256,18 @@ func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medal
for _, row := range rows {
for i := 0; i < row.Gold; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 1, medal: medalGold})
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID,
entryID: rr.EntryID, rank: 1, medal: medalGold})
}
for i := 0; i < row.Silver; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 2, medal: medalSilver})
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID,
entryID: rr.EntryID, rank: 2, medal: medalSilver})
}
for i := 0; i < row.Bronze; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 3, medal: medalBronze})
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID,
entryID: rr.EntryID, rank: 3, medal: medalBronze})
}
}
}
@@ -385,7 +409,7 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext) []MedalDetailRow {
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam) []MedalDetailRow {
list := hits[teamID]
rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list {
@@ -393,7 +417,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
if !ok {
c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}]
}
rows = append(rows, MedalDetailRow{
row := MedalDetailRow{
SportName: c.sportName,
EventName: c.eventName,
Category: c.category,
@@ -406,7 +430,15 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
eventID: c.eventID,
categoryID: c.categoryID,
resultID: h.resultID,
})
}
// 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。
// 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。
if h.entryID != 0 {
if joint, ok := joints[h.entryID]; ok {
row.jointNote = joint.medalCell(h.medal)
}
}
rows = append(rows, row)
}
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
+3
View File
@@ -343,6 +343,9 @@ func metaSubtitle(m Meta) string {
if m.GeneratedBy != "" {
parts = append(parts, "导出人 "+m.GeneratedBy)
}
if m.Note != "" {
parts = append(parts, m.Note)
}
return strings.Join(parts, " · ")
}
+7 -1
View File
@@ -126,9 +126,15 @@ func LoadAllTeamDetailsWorkbook(db *sql.DB, opts Options) (AllTeamDetailsWorkboo
}
orderTeamsByStandings(teams, order)
// 联队配置读一次给全部单位分表复用(几个单位就查几遍纯属浪费)。
joints, err := loadJointTeams(db)
if err != nil {
return AllTeamDetailsWorkbook{}, err
}
sheets := make([]TeamDetailSheet, 0, len(teams))
for _, t := range teams {
rows, err := loadTeamDetailRows(db, t.ID)
rows, err := loadTeamDetailRows(db, t.ID, joints)
if err != nil {
return AllTeamDetailsWorkbook{}, err
}
+18
View File
@@ -129,6 +129,24 @@ type entryDTO struct {
TeamName string `json:"teamName,omitempty"`
ShortName *string `json:"shortName,omitempty"`
CategoryName string `json:"categoryName,omitempty"`
// TeamShares 是这条报名的归属份额(联队):读侧至少一条,第一条是主单位(= teamId)。
// 普通报名也下发一条独占份额,前端不必为「有没有联队」写两套渲染。
TeamShares []entryTeamShareDTO `json:"teamShares"`
}
// entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。
//
// - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价;
// 入库也就存原值,不做归一,避免 2:1 被写成 0.666…/0.333… 这种除不尽的数。
// - medal :这枚奖牌是否**整枚**记给该单位(缺省 true)。联队的金牌两个单位各记 1 枚,
// 与 weight 无关——奖牌榜数整数枚,不按积分权重拆。
// - teamCode / teamName 是读侧补的展示字段(前端拼「A+B联队」用),写侧不需要带。
type entryTeamShareDTO struct {
TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"`
Medal *bool `json:"medal,omitempty"`
TeamCode string `json:"teamCode,omitempty"`
TeamName string `json:"teamName,omitempty"`
}
type entryWithResultDTO struct {
@@ -433,6 +433,56 @@ func entryTemplateHeader(language string) []string {
return out
}
// 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位,
// 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。
//
// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、12 列
// 一字不动),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见)
// 与这一列的**输入提示**(点中格子就弹出来)。
const (
entryJointHintZH = "单位编码怎么写:\n" +
"· 单个单位参赛:填它的编码,例如 A。\n" +
"· 同一小项组别里两个单位联合参赛(联队):写 A+B,加号左边那个是主单位。\n" +
"  积分在两个单位之间平均分(各 50%,拆出 0.5 分照实记);\n" +
"  奖牌两边各记一枚整牌——联队的金牌,A、B 各记 1 枚。"
entryJointPromptZH = "联队写 A+B(加号左边是主单位):积分平均分,奖牌两边各记一枚"
entryJointTitleZH = "单位编码怎么写"
entryJointHintEN = "How to fill the team code:\n" +
"- One unit: its code, for example A.\n" +
"- Two units entering together as a joint team (same event and category): write A+B, " +
"the left one is the primary unit.\n" +
" Points are split evenly between them (50% each; halves such as 7.5 are kept as they are);\n" +
" each unit is credited one full medal - a joint gold counts as one for A and one for B."
entryJointPromptEN = "Joint team: write A+B (left one is the primary unit). " +
"Points split evenly, each unit gets one full medal"
entryJointTitleEN = "How to fill the team code"
// entryTemplateAuthor 是批注作者。写系统名(与导出件的文档属性同一套),
// 绝不写构建期注入的内部署名。
entryTemplateAuthor = "赛事积分榜系统"
// entryTemplateHintRows 是输入提示覆盖的行数:模板是空表,给填表人留够行即可。
entryTemplateHintRows = 500
)
// entryTemplateJointHint 按表头语言取「联队怎么写」的说明(批注正文 + 输入提示的标题与正文)。
func entryTemplateJointHint(language string) (hint, title, prompt string) {
if language == "en" {
return entryJointHintEN, entryJointTitleEN, entryJointPromptEN
}
return entryJointHintZH, entryJointTitleZH, entryJointPromptZH
}
// entryTeamCodeColumn 是 team_code 在表头里的列号(0 起)。模板的说明挂在这一列上,
// 所以它从 importColumns 现算,改了列序也不会挂错格子。
func entryTeamCodeColumn() int {
for i, column := range importColumns {
if column.name == "team_code" {
return i
}
}
return -1
}
// entryTemplateFilename 是两种表头语言的下载文件名,前端按同一份口径拼文件名。
func entryTemplateFilename(language string) string {
if language == "en" {
@@ -488,9 +538,13 @@ func (s *server) entriesTemplateXLSX(w http.ResponseWriter, r *http.Request) (an
}
headers := entryTemplateHeader(language)
hint, hintTitle, prompt := entryTemplateJointHint(language)
sheets := make([]entriesTemplateSheet, 0, len(plan.sheets))
for _, planned := range plan.sheets {
sheets = append(sheets, entriesTemplateSheet{Name: planned.Name, Headers: headers})
sheets = append(sheets, entriesTemplateSheet{
Name: planned.Name, Headers: headers,
JointHint: hint, JointHintTitle: hintTitle, JointPrompt: prompt,
})
}
name := entryTemplateFilename(language)
ascii := "entries-template-" + language + ".xlsx"
@@ -518,6 +572,11 @@ func entrySheetExcludedMessage(problems []string) string {
type entriesTemplateSheet struct {
Name string
Headers []string
// JointHint / JointHintTitle / JointPrompt 是「单位编码写 A+B」的说明(按表头语言取),
// 挂在该列的批注与输入提示上。
JointHint string
JointHintTitle string
JointPrompt string
}
// writeEntriesTemplateXLSX 写整份模板工作簿。
@@ -580,6 +639,9 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro
if err := f.SetRowHeight(sheet.Name, 1, 24); err != nil {
return err
}
if err := writeEntryJointTeamHint(f, sheet); err != nil {
return err
}
// 冻结表头:一张表几十行,往下翻的时候列名得一直看得见。
if err := f.SetPanes(sheet.Name, &excelize.Panes{
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
@@ -592,3 +654,45 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro
}
return nil
}
// writeEntryJointTeamHint 把「联队怎么写」挂在单位编码列上:表头那一格一条批注(有红三角),
// 整列一条输入提示(点中格子就弹出)。
//
// 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是
// 「第 1 行 12 列表头 + 数据行」,批注与输入提示不是数据。
func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error {
column := entryTeamCodeColumn()
if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" {
return nil
}
name, err := excelize.ColumnNumberToName(column + 1)
if err != nil {
return err
}
if err := f.AddComment(sheet.Name, excelize.Comment{
Cell: name + "1",
Author: entryTemplateAuthor,
Text: sheet.JointHint,
Width: 360,
Height: 170,
}); err != nil {
return fmt.Errorf("httpapi: 写模板批注 %s!%s1: %w", sheet.Name, name, err)
}
if sheet.JointPrompt == "" {
return nil
}
// 刻意用 type="none":这里只要一条提示,不要任何取值限制——现场的表千奇百怪,
// 模板不该因为一条校验规则把好好的编码挡在门外。
dv := excelize.NewDataValidation(true)
dv.SetSqref(fmt.Sprintf("%s2:%s%d", name, name, entryTemplateHintRows))
dv.Type = "none"
title := sheet.JointHintTitle
if title == "" {
title = "单位编码"
}
dv.SetInput(title, sheet.JointPrompt)
if err := f.AddDataValidation(sheet.Name, dv); err != nil {
return fmt.Errorf("httpapi: 写模板输入提示 %s!%s: %w", sheet.Name, name, err)
}
return nil
}
+421
View File
@@ -0,0 +1,421 @@
package httpapi
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是「联队 + 份额分成」的读写与解析(devdocs/19)。
//
// 表达方式:**联队是一条报名,不是一个新单位**。新建/修改报名时给多个成员单位(默认均分),
// 分数与奖牌由引擎按份额下发到各单位;entries.team_id 仍是主单位(加号左边那个),
// 既有查询、唯一键、榜单兜底都还认它。
//
// 与导入的关系:报名表的 team_code 列写 `A+B` 就是联队(默认均分),解析见 parseJointTeamCodes。
// maxJointTeamMembers 是联队的成员单位上限。现场最多见过三个单位合并,给到 5 已经远超需要;
// 上限的作用是拦住「一整格都是加号」这类误填,而不是限制业务。
const maxJointTeamMembers = 5
// parseJointTeamCodes 解析报名表 team_code 单元格:`A` → 单单位,`A+B` → 联队(A 是主单位)。
//
// 全角 + 与两侧空格都当作分隔符的一部分(现场用中文输入法填表,打出来的常是全角),
// 但**只做这一层归一**:分段为空(`A+`、`+B`、`A++B`)、重复(`A+A`)、超长一律报错。
// 报错必须说清「这一格该怎么写」——现场看到一句「编码非法」是没法照着改的。
func parseJointTeamCodes(raw string) ([]string, error) {
normalized := strings.ReplaceAll(raw, "+", "+")
parts := strings.Split(normalized, "+")
codes := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
code := strings.TrimSpace(part)
if code == "" {
return nil, fmt.Errorf("单位编码 %s 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B)", raw)
}
if seen[code] {
return nil, fmt.Errorf("单位编码 %s 里同一个单位出现了两次:联队的每个单位只能写一次", raw)
}
seen[code] = true
codes = append(codes, code)
}
if len(codes) > maxJointTeamMembers {
return nil, fmt.Errorf("单位编码 %s 里写了 %d 个单位:一个联队最多 %d 个单位",
raw, len(codes), maxJointTeamMembers)
}
return codes, nil
}
// lookupImportTeams 把 team_code 解析成单位 id 与份额:普通报名一个单位,联队多个(默认均分)。
//
// 返回的份额就是写库要用的那套:每条权重 1(均分),medal 缺省(整枚各记)。
// 主单位是 codes[0](加号左边那个),它进 entries.team_id。
//
// 报错都是给现场看的行级文案,不包 apiError:调用方(导入主循环)用 err.Error() 原样回显,
// 多一层前缀现场反而看不懂。单位不存在时必须点明是联队里的哪一段,否则一行 `A+B` 里
// 找不到 B 的时候,录入员会以为是 A 写错了。
func (s *server) lookupImportTeams(ctx context.Context, teamCode string) ([]int64, []entryTeamShareDTO, error) {
codes, err := parseJointTeamCodes(teamCode)
if err != nil {
return nil, nil, err
}
ids := make([]int64, 0, len(codes))
shares := make([]entryTeamShareDTO, 0, len(codes))
for _, code := range codes {
id, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, code)
if err != nil {
if len(codes) > 1 {
return nil, nil, fmt.Errorf("单位 %s 不存在(来自联队编码 %s)", code, teamCode)
}
return nil, nil, fmt.Errorf("单位 %s 不存在", code)
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, id); err != nil {
return nil, nil, err
}
ids = append(ids, id)
shares = append(shares, entryTeamShareDTO{TeamID: id, Weight: 1})
}
return ids, shares, nil
}
// entryTeamSharesReady 判断份额表在不在。
//
// 读路径要能读「还没跑到 0024 的库」:迁移在服务启动时补跑(main.go),但榜单可能在迁移之前
// 被读一次——0022 的老库回归用例就是在迁移前读榜的,它读的正是升级前那份错榜。
// 表不在 = 库里没有任何联队配置 = 主单位独占 100%,正是老数据本来就该得到的结果,
// 所以按「没有份额」处理是**精确**的,不是容错降级(份额表缺表时不可能丢掉真实配置)。
func entryTeamSharesReady(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_team_shares'`).Scan(&n); err != nil {
return false, err
}
return n != 0, nil
}
// loadEntryTeamSharesQ 读一条 SQL 里给定范围内报名的份额,按 entry_id 归组。
// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。
//
// 排序按 (sort_order, team_id):主单位排第一是由写侧保证的,引擎不依赖顺序取分,
// 但展示与流水顺序要稳定,否则同一份数据两次导出会排出两个样子。
func loadEntryTeamSharesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.TeamShare, error) {
ready, err := entryTeamSharesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
query := `SELECT s.entry_id, s.team_id, s.points_share, s.medal_share
FROM entry_team_shares s`
args := []any{}
if eventID != 0 {
query += ` JOIN entries e ON e.id = s.entry_id WHERE e.event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY s.entry_id, s.sort_order, s.team_id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, internal(err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
// loadEntrySharesDTOs 读某小项全部报名的份额读数形态:带上单位编码 / 名称,
// 前端据此拼「A+B联队」,不必再查一次单位表。
func (s *server) loadEntrySharesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryTeamShareDTO, error) {
ready, err := entryTeamSharesReady(ctx, q)
if err != nil {
return nil, internal(err)
}
if !ready {
return nil, nil
}
rows, err := q.QueryContext(ctx,
`SELECT s.entry_id, s.team_id, s.points_share, s.medal_share, t.code, t.name
FROM entry_team_shares s
JOIN entries e ON e.id = s.entry_id
JOIN teams t ON t.id = s.team_id
WHERE e.event_id = ?
ORDER BY s.entry_id, s.sort_order, s.team_id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]entryTeamShareDTO)
for rows.Next() {
var (
entryID int64
item entryTeamShareDTO
medal int
)
if err := rows.Scan(&entryID, &item.TeamID, &item.Weight, &medal, &item.TeamCode, &item.TeamName); err != nil {
return nil, internal(err)
}
if medal == 0 {
excluded := false
item.Medal = &excluded
}
out[entryID] = append(out[entryID], item)
}
return out, rows.Err()
}
// entrySharesOf 把一条报名的份额补齐成「至少一条」:库里没有份额行时按主单位独占 100%。
// 与引擎的 domain.ResolveShares 同口径(那边用 []domain.TeamShare,这边还要带展示字段)。
// 主单位的编码 / 名称由调用方给(报名查询里本来就 join 了单位表)。
func entrySharesOf(configured []entryTeamShareDTO, primaryTeamID int64, code, name string) []entryTeamShareDTO {
if len(configured) > 0 {
return configured
}
if primaryTeamID == 0 {
return nil
}
return []entryTeamShareDTO{{TeamID: primaryTeamID, Weight: 1, TeamCode: code, TeamName: name}}
}
// replaceEntryShares 按给定顺序重写一条报名的份额(先删后插,幂等)。
//
// 为什么每次都重写、而不是「有联队才写」:报名可以被改成另一个样子(联队改回单单位、
// 换成员单位)。只增不减会留下旧份额,那条报名以后一直按早就改掉的配置分账。
// 传空份额时按主单位独占补一条,保持「读侧至少一条」这个不变式。
func replaceEntryShares(ctx context.Context, q queryer, entryID, primaryTeamID int64, shares []entryTeamShareDTO) error {
if _, err := q.ExecContext(ctx, `DELETE FROM entry_team_shares WHERE entry_id = ?`, entryID); err != nil {
return internal(err)
}
if len(shares) == 0 {
shares = []entryTeamShareDTO{{TeamID: primaryTeamID, Weight: 1}}
}
for i, s := range shares {
medal := 1
if !entryShareCountsMedal(s) {
medal = 0
}
if _, err := q.ExecContext(ctx,
`INSERT INTO entry_team_shares (entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, ?, ?, ?)`,
entryID, s.TeamID, entryShareWeight(s), medal, i); err != nil {
return internal(err)
}
}
return nil
}
// sharesContainTeam 判断份额里有没有这个单位(导入侧用它核对「主单位还在不在成员里」)。
func sharesContainTeam(shares []entryTeamShareDTO, teamID int64) bool {
for _, s := range shares {
if s.TeamID == teamID {
return true
}
}
return false
}
// entryShareCountsMedal 与 domain.TeamShare.CountsMedal 同一口径:缺省(nil)= 整枚各记。
func entryShareCountsMedal(s entryTeamShareDTO) bool {
return s.Medal == nil || *s.Medal
}
// entryShareWeight 与 domain 的 shareWeight 同一口径:0(不填)按 1 处理,也就是均分。
// 负数在 validateEntryShares 就被拦下,这里的兜底只是让写库不会撞上 CHECK。
func entryShareWeight(s entryTeamShareDTO) float64 {
if s.Weight <= 0 {
return 1
}
return s.Weight
}
// validateEntryShares 校验一次份额改动,返回给人看的 400 错误。
//
// 规则(每一条都对着一种会算错分的写法):
// - 不能为空:份额为空等于没说清这条报名的分该给谁;
// - 每个单位都得存在且没被假删除:删掉的单位不该再收分;
// - 权重不能是负数:份额是相对权重,不填(0)就是 1——「均分」的写法,负数没有任何含义;
// - 同一个单位只能出现一次:重复会让同一条成绩给同一个单位记两份分、两枚牌;
// - 必须包含主单位(entries.team_id):主单位是这条报名在库里的身份(唯一键用它),
// 把它排除在份额之外,只会得到「报名挂在 A 头上、分全给了 B」这种看不见的错。
func (s *server) validateEntryShares(ctx context.Context, primaryTeamID int64, shares []entryTeamShareDTO) error {
if len(shares) == 0 {
return badRequest("teamShares 不能为空:至少要有主单位一条", nil)
}
if len(shares) > maxJointTeamMembers {
return badRequest(fmt.Sprintf("teamShares 最多 %d 个单位:一个联队不需要这么多成员", maxJointTeamMembers), nil)
}
ids := make([]int64, 0, len(shares))
seen := make(map[int64]bool, len(shares))
hasPrimary := false
for i, s := range shares {
if s.TeamID == 0 {
return badRequest("teamShares[].teamId 必填", i)
}
if seen[s.TeamID] {
return badRequest(fmt.Sprintf("teamShares 里单位 %d 出现了两次:同一个单位只能占一份", s.TeamID), i)
}
seen[s.TeamID] = true
if s.Weight < 0 {
return badRequest(fmt.Sprintf("teamShares[].weight 不能为负数(当前 %v):份额是相对权重,不填或填 0 就按 1 算(均分)", s.Weight), i)
}
if s.TeamID == primaryTeamID {
hasPrimary = true
}
ids = append(ids, s.TeamID)
}
if !hasPrimary {
return badRequest(fmt.Sprintf("teamShares 必须包含这条报名的主单位 %d:主单位不能从份额里去掉(换主单位要重新建报名)", primaryTeamID), nil)
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id FROM teams WHERE deleted_at IS NULL AND 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("teamId 不存在或已删除", missing)
}
return nil
}
// loadEntryForShares 读一条报名的落点(小项 / 组别 / 主单位),份额改动要用它决定重算范围。
func (s *server) loadEntryForShares(ctx context.Context, entryID int64) (eventID, categoryID, teamID int64, err error) {
err = s.ev.QueryRowContext(ctx,
`SELECT event_id, category_id, team_id FROM entries WHERE id = ?`, entryID).
Scan(&eventID, &categoryID, &teamID)
if err == sql.ErrNoRows {
return 0, 0, 0, notFound("报名项不存在")
}
if err != nil {
return 0, 0, 0, internal(err)
}
return eventID, categoryID, teamID, nil
}
// updateEntryTeamShares 改写一条报名的份额并重算它所在的小项组别。
//
// 必须重算:份额决定同一条成绩的分记在谁头上,不重算就是「界面显示改好了、榜单还是旧分」。
// 奖牌不需要单独处理——它由读取侧按 entry_team_shares 现算,落地即生效。
func (s *server) updateEntryTeamShares(ctx context.Context, entryID int64, shares []entryTeamShareDTO, actorID int64) (entryDTO, error) {
eventID, categoryID, teamID, err := s.loadEntryForShares(ctx, entryID)
if err != nil {
return entryDTO{}, err
}
if err := s.validateEntryShares(ctx, teamID, shares); 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 := replaceEntryShares(ctx, tx, entryID, teamID, shares); err != nil {
return entryDTO{}, err
}
score, err := recomputeLedgerTx(ctx, tx, eventID, 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, 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)
}
// getEntry 读回单条报名(含份额),供份额改写接口回显。
func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, entryID))
if err != nil {
if err == sql.ErrNoRows {
return entryDTO{}, notFound("报名项不存在")
}
return entryDTO{}, internal(err)
}
shares, err := s.loadEntrySharesDTOs(ctx, s.ev, e.EventID)
if err != nil {
return entryDTO{}, err
}
e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName)
return e, nil
}
// updateEntryTeamSharesHandler 是 PUT /api/entries/{id}/team-shares:
// 改一条报名的成员单位与权重(联队),成功后返回这条报名的完整读数。
func (s *server) updateEntryTeamSharesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in entryTeamSharesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getEntry(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEntryTeamShares(r.Context(), id, in.TeamShares, middleware.User(r.Context()).ID)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.team_shares.update", "entry", id,
map[string]any{"teamShares": before.TeamShares}, map[string]any{"teamShares": updated.TeamShares})
return updated, nil
}
@@ -0,0 +1,396 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住「联队 + 份额分成」从导入到榜单的完整链路(devdocs/19):
// - 报名表 team_code 写 `A+B` → 一条报名挂两个单位(默认均分),主单位是加号左边那个;
// - 团体第 1 名 15 分 → 两个单位各 7.5 分;金牌两个单位**各记 1 枚**(不是各 0.5 枚);
// - 权重可配(2:1 → 10 / 5),改完当场重算推榜;
// - 非法写法(单位不存在 / 缺一段 / 重复 / 太多)必须拦下并给出照得着改的报错。
// seedMatchEvent 造一个「球类」小项:match 类型 + 按单位录名次的团体通道
// (规则包给 match 的模板就是 scope=team + basis=manual),返回小项与组别 id。
func seedMatchEvent(t *testing.T, env *testEnv, ids seedIDs) (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 (?, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 1, 1, 'ongoing')`, ids.sport)
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 (?, 'team', 'manual', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
return eventID, categoryID
}
// jointImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeader 一致。
func jointImportRow(eventCode, category, teamCode string) string {
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/李强"}, ",")
}
// importCSV 上传一份单表 CSV(表头 + 若干行)。
func importCSV(t *testing.T, env *testEnv, client *http.Client, rows ...string) (int, envelope) {
t.Helper()
content := importHeader + "\n" + strings.Join(rows, "\n") + "\n"
return env.postImport(t, client, "/api/entries/import", content, "")
}
// sharesDump 导出某小项「报名 + 份额」的原始落库事实(列序见下面的查询):
// share_team 为 "<nil>" 表示这条报名一条份额都没有(不该出现)。
func sharesDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT e.id AS entry_id, COALESCE(e.athlete_id, 0) AS athlete_id, e.team_id,
COALESCE(s.team_id, 0) AS share_team, COALESCE(s.points_share, 0) AS points_share,
COALESCE(s.medal_share, 0) AS medal_share, COALESCE(s.sort_order, 0) AS sort_order
FROM entries e
LEFT JOIN entry_team_shares s ON s.entry_id = e.id
WHERE e.event_id = %d
ORDER BY e.id, s.sort_order, s.team_id`, eventID))
}
// shareRow 按 tableDump 的格式拼一行份额(列名=值,行尾一个空格)。
func shareRow(entryID, athleteID, teamID, shareTeam int64, weight float64, medal, order int) string {
return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=%d points_share=%v medal_share=%d sort_order=%d \n",
entryID, athleteID, teamID, shareTeam, weight, medal, order)
}
// shareAbsentRow 是「这条报名一条份额行都没有」的 dump 行:种子 / 手工造的报名直接写 entries,
// 绕过了导入,库里就没有份额行——读侧与引擎都按主单位独占 100% 兜底(老数据同理)。
func shareAbsentRow(entryID, athleteID, teamID int64) string {
return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=0 points_share=0 medal_share=0 sort_order=0 \n",
entryID, athleteID, teamID)
}
// firstEntryID 取某小项的第一条报名 id。
func firstEntryID(t *testing.T, env *testEnv, eventID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? ORDER BY id LIMIT 1`, eventID).Scan(&id); err != nil {
t.Fatalf("读报名项: %v", err)
}
return id
}
// standingsRow 取积分榜上某个单位那一行(REST /api/standings 的原始读数)。
func standingsRow(t *testing.T, env *testEnv, client *http.Client, teamID int64) map[string]any {
t.Helper()
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, resp.Error)
}
for _, row := range decodeData[[]map[string]any](t, resp) {
if row["teamId"] == float64(teamID) {
return row
}
}
t.Fatalf("积分榜上没有单位 %d", teamID)
return nil
}
// assertStanding 断言某单位的总分与金银铜。
func assertStanding(t *testing.T, env *testEnv, client *http.Client, teamID int64, points float64, gold, silver, bronze int) {
t.Helper()
row := standingsRow(t, env, client, teamID)
if got := row["totalPoints"].(float64); got != points {
t.Errorf("单位 %d 总分 = %v,期望 %v(整行:%+v)", teamID, 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)", teamID, name, got, want, row)
}
}
}
// 现场场景:两个单位因人数不足合并参赛,团体第 1 名 15 分 → A、B 各 7.5 分、各 1 枚金牌。
func TestImportJointTeamSplitsPointsAndMedals(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID := seedMatchEvent(t, env, ids)
status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[map[string]any](t, resp); result["created"].(float64) != 1 {
t.Fatalf("应新建 1 条报名,得到 %+v", result)
}
// 主单位是加号左边那个(A),份额表里两条均分、奖牌各记整枚。
entryID := firstEntryID(t, env, eventID)
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) +
shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1)
if got := sharesDump(t, env, eventID); got != want {
t.Fatalf("联队报名落库不对:\n得到 %s期望 %s", got, want)
}
// 团体名次录在主单位上(现场就是按单位录名次):联队第 1 名。
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
}); status != http.StatusOK {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
// 积分各 7.5(允许出现小数),金牌各 1 枚。
assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0)
assertStanding(t, env, client, ids.teamB, 7.5, 1, 0, 0)
ledger := tableDump(t, env, `SELECT team_id, points FROM score_ledger WHERE voided = 0 ORDER BY team_id`)
wantLedger := fmt.Sprintf("team_id=%d points=7.5 \nteam_id=%d points=7.5 \n", ids.teamA, ids.teamB)
if ledger != wantLedger {
t.Fatalf("积分流水应按份额拆成两条:\n得到 %s期望 %s", ledger, wantLedger)
}
}
// 权重可配:改成 2:1 → 10 / 5;奖牌仍是各 1 枚(两个口径互不影响)。
func TestUpdateEntryTeamSharesWeights(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID := seedMatchEvent(t, env, ids)
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK {
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
}); status != http.StatusOK {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0)
path := fmt.Sprintf("/api/entries/%d/team-shares", entryID)
status, resp := env.do(client, http.MethodPut, path, map[string]any{
"teamShares": []map[string]any{
{"teamId": ids.teamA, "weight": 2},
{"teamId": ids.teamB, "weight": 1},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改份额应 200,得到 %d %+v", status, resp.Error)
}
updated := decodeData[map[string]any](t, resp)
shares, _ := updated["teamShares"].([]any)
if len(shares) != 2 {
t.Fatalf("回显应带两条份额,得到 %+v", updated["teamShares"])
}
first, _ := shares[0].(map[string]any)
if first["teamId"] != float64(ids.teamA) || first["weight"] != float64(2) {
t.Fatalf("回显的第一条应是主单位 A 权重 2,得到 %+v", first)
}
if first["teamCode"] != "A" || first["teamName"] != "第一分公司" {
t.Fatalf("回显应带上单位编码与名称(前端拼「A+B联队」用),得到 %+v", first)
}
// 改完当场重算:2:1 拆成 10 / 5,奖牌不受权重影响,仍是各 1 枚。
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamB, 5, 1, 0, 0)
// 奖牌开关按单位存(medal=false 落成 medal_share=0 再读回来),积分照旧按权重拆。
status, resp = env.do(client, http.MethodPut, path, map[string]any{
"teamShares": []map[string]any{
{"teamId": ids.teamA, "weight": 2},
{"teamId": ids.teamB, "weight": 1, "medal": false},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("关掉成员单位的奖牌应 200,得到 %d %+v", status, resp.Error)
}
shares, _ = decodeData[map[string]any](t, resp)["teamShares"].([]any)
second, _ := shares[1].(map[string]any)
if second["medal"] != false {
t.Fatalf("回显应带上「这个单位不记奖牌」(medal:false),得到 %+v", second)
}
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamB, 5, 0, 0, 0)
}
// 非法份额的报错必须说清怎么改(人话),且一律不落库。
func TestUpdateEntryTeamSharesRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedMatchEvent(t, env, ids)
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入单人报名应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
path := fmt.Sprintf("/api/entries/%d/team-shares", entryID)
baseline := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`)
cases := []struct {
name string
body map[string]any
want string
}{
{"空份额", map[string]any{"teamShares": []map[string]any{}}, "teamShares 不能为空"},
{"缺主单位", map[string]any{"teamShares": []map[string]any{{"teamId": ids.teamB}}}, "必须包含这条报名的主单位"},
{"单位重复", map[string]any{"teamShares": []map[string]any{
{"teamId": ids.teamA}, {"teamId": ids.teamA}}}, "出现了两次"},
{"权重为负", map[string]any{"teamShares": []map[string]any{
{"teamId": ids.teamA, "weight": -1}}}, "不能为负数"},
{"单位不存在", map[string]any{"teamShares": []map[string]any{
{"teamId": ids.teamA}, {"teamId": 999999}}}, "不存在或已删除"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := env.do(client, http.MethodPut, path, tc.body, true)
if status != http.StatusBadRequest {
t.Fatalf("应 400,得到 %d %+v", status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
}
// 拦下就不能落库:份额表必须一个字节都没动。
if got := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`); got != baseline {
t.Fatalf("被拒的请求改动了份额表:\n之前 %s之后 %s", baseline, got)
}
})
}
}
// 报名表里非法写法的联队:拦下这一行并给出照得着改的报错,其余行照常导入。
func TestImportJointTeamRejectsBadForms(t *testing.T) {
cases := []struct {
name string
teamCode string
want string
}{
{"成员单位不存在", "A+ZZ", "单位 ZZ 不存在(来自联队编码 A+ZZ)"},
{"加号后面缺一段", "A+", "里有一段是空的"},
{"加号前面缺一段", "+B", "里有一段是空的"},
{"同一个单位写两遍", "A+A", "同一个单位出现了两次"},
{"成员太多", "A+B+C+D+E+F", "一个联队最多 5 个单位"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedMatchEvent(t, env, ids)
// 前面一行是正常的单单位报名:它必须照常导入(坏行不能拖垮整张表)。
status, resp := importCSV(t, env, client,
jointImportRow("BALL", "男子", "A"),
jointImportRow("BALL", "男子", tc.teamCode),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入端点应 200(逐行报错),得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["created"].(float64) != 1 || result["skipped"].(float64) != 1 {
t.Fatalf("应 1 条成功 1 条跳过,得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(fmt.Sprint(errs[0]), tc.want) {
t.Fatalf("第 2 行的报错应含 %q,得到 %+v", tc.want, errs)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 1 {
t.Fatalf("坏行不该落库,报名数 = %d(明细:\n%s)", n, sharesDump(t, env, eventID))
}
// 成功那行必须是「主单位独占」一条:坏行的解析结果不能污染它。
entryID := firstEntryID(t, env, eventID)
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
if got := sharesDump(t, env, eventID); got != want {
t.Fatalf("正常行应只挂主单位一条份额:\n得到 %s期望 %s", got, want)
}
})
}
}
// 全角加号与两侧空格照样认(现场用中文输入法填表,打出来的常是全角)。
func TestImportJointTeamAcceptsFullWidthPlus(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedMatchEvent(t, env, ids)
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A + B")); status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) +
shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1)
if got := sharesDump(t, env, eventID); got != want {
t.Fatalf("全角加号应解析成同一个联队:\n得到 %s期望 %s", got, want)
}
}
// 联队改回单单位(或换成员单位)时份额必须跟着变:只增不改会把旧配置一直用下去。
func TestImportJointTeamResetsSharesOnReimport(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedMatchEvent(t, env, ids)
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK {
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
}
status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传单单位应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[map[string]any](t, resp); result["updated"].(float64) != 1 {
t.Fatalf("应更新同一行,得到 %+v", result)
}
entryID := firstEntryID(t, env, eventID)
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
if got := sharesDump(t, env, eventID); got != want {
t.Fatalf("改回单单位后份额应只剩主单位一条:\n得到 %s期望 %s", got, want)
}
}
// 单个单位的报名照旧:库里写一条「主单位独占 100%」,读接口也下发一条,前端不必分支。
func TestSingleUnitEntryKeepsWholeShare(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID := seedMatchEvent(t, env, ids)
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := firstEntryID(t, env, eventID)
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
if got := sharesDump(t, env, eventID); got != want {
t.Fatalf("单单位报名应写一条独占份额:\n得到 %s期望 %s", got, want)
}
board := getEntryBoard(t, env, client, eventID)
if len(board.Entries) != 1 {
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
}
shares, _ := board.Entries[0]["teamShares"].([]any)
if len(shares) != 1 {
t.Fatalf("读接口应下发一条份额(普通报名也一样),得到 %+v", board.Entries[0]["teamShares"])
}
share, _ := shares[0].(map[string]any)
if share["teamId"] != float64(ids.teamA) || share["weight"] != float64(1) || share["teamCode"] != "A" {
t.Fatalf("份额读数应为 {teamId:A, weight:1, teamCode:A},得到 %+v", share)
}
// 独占口径下名次与积分照旧:第 1 名 15 分全给 A,B 一分没有。
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
}); status != http.StatusOK {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
assertStanding(t, env, client, ids.teamA, 15, 1, 0, 0)
assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0)
}
// 人员匹配可能把报名改挂到库里那条档案的单位(keep-existing 的既有口径「报名跟随库里已有的单位」):
// 份额必须跟着**最终主单位**走。跟不上的话就会出现「报名挂在 A 名下、分却记给了表格里写的 B」——
// 现场看不出来,但榜单直接是错的。
func TestImportSharesFollowMatchedUnit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
// 表里写 B,库里这个人的单位是 A,用户选 keep-existing → 报名仍挂在 A。
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
status, resp := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !resp.OK {
t.Fatalf("决策导入应 200,得到 %d %+v", status, resp.Error)
}
want := shareRow(ids.entryA, ids.athleteA, ids.teamA, ids.teamA, 1, 1, 0) +
shareAbsentRow(ids.entryB, ids.athB, ids.teamB)
if got := sharesDump(t, env, ids.event); got != want {
t.Fatalf("份额应跟随最终主单位 A:\n得到 %s期望 %s", got, want)
}
// 榜单上分也只能记给 A:报名挂在 A 名下,B 一分没有。
publishResults(t, env, client, ids.event, "shares-follow-matched-unit",
map[string]any{"entryId": ids.entryA, "resultValue": 11.2, "resultText": "11.2", "unit": "秒", "status": "ok"})
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0)
}
+14 -8
View File
@@ -1291,19 +1291,16 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
continue
}
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
// team_code 写 `A+B` 就是联队(devdocs/19):解析出多个成员单位,第一个是主单位,
// 分与奖牌按份额下发给各单位。成员单位的存在性、复活与报错都在里面,
// 报错文案与单单位口径逐字一致(现场几百份老表的提示格式不该变样)。
teamIDs, teamShares, err := s.lookupImportTeams(ctx, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
continue
}
teamID := teamIDs[0]
if table.scope == nil {
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
@@ -1427,10 +1424,19 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
teamID = matched.TeamID
}
// 份额必须跟着**最终**的主单位走:上面的人员匹配可能把报名改挂到库里那条档案的单位
// (keep-existing 的既有口径「报名跟随库里已有的单位」)。主单位掉到表格写的成员之外时
// 整组退回独占,与升级前一致——报名挂在哪个单位,分就记给哪个单位;
// 否则会出现「报名挂在 A 名下、分却记给了表格里写的 B」这种看不见的错。
if !sharesContainTeam(teamShares, teamID) {
teamShares = nil
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
TeamShares: teamShares,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(bib),
@@ -0,0 +1,101 @@
package httpapi_test
import (
"net/http"
"strings"
"testing"
)
// 报名模板要让填表人知道联队怎么写(devdocs/19):单位编码写 `A+B`,加号左边那个是主单位,
// 积分平均分、奖牌两边各记一枚。
//
// 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、
// 12 列一成不变),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
// 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。
func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, lang := range []string{"zh", "en"} {
f, _ := env.fetchTemplate(t, client, lang)
sheets := f.GetSheetList()
if len(sheets) == 0 {
t.Fatalf("lang=%s 模板没有工作表", lang)
}
for _, sheet := range sheets {
comments, err := f.GetComments(sheet)
if err != nil {
t.Fatalf("[%s] 读工作表 %s 的批注: %v", lang, sheet, err)
}
if len(comments) != 1 {
t.Fatalf("[%s] 工作表 %s 应有且仅有 1 条批注(挂在单位编码表头),得到 %d 条", lang, sheet, len(comments))
}
comment := comments[0]
if comment.Cell != "C1" {
t.Errorf("[%s] 批注应挂在单位编码表头 C1,得到 %s", lang, comment.Cell)
}
if !strings.Contains(comment.Text, "A+B") {
t.Errorf("[%s] 批注没写清联队怎么写:%q", lang, comment.Text)
}
// 批注作者写系统名(与导出件的文档属性同一套),不是构建期注入的内部署名。
if comment.Author != "赛事积分榜系统" {
t.Errorf("[%s] 批注作者 = %q,期望系统名", lang, comment.Author)
}
validations, err := f.GetDataValidations(sheet)
if err != nil {
t.Fatalf("[%s] 读工作表 %s 的输入提示: %v", lang, sheet, err)
}
prompt := ""
for _, dv := range validations {
if dv.Sqref != "" && strings.HasPrefix(dv.Sqref, "C2") && dv.Prompt != nil {
prompt = *dv.Prompt
}
}
if !strings.Contains(prompt, "A+B") {
t.Errorf("[%s] 单位编码列的输入提示应写明联队写法,得到 %q", lang, prompt)
}
}
}
}
// 模板上的中文说明必须与导入的实际行为一致:照说明填 `A+B`,导进来就是一条报名挂两个单位、均分。
func TestEntriesTemplateJointTeamRowImports(t *testing.T) {
env, ids, client := recorderWithSeed(t)
f, _ := env.fetchTemplate(t, client, "")
// 范围由表名承担,「项目编码 / 组别」两列留空;单位编码按模板说明写 A+B。
appendRows(t, f, "100米跑@男子", [][]string{{
"", "", "A+B", "联队小张", "110101199001011234", "男", "26", "301", "", "1", "registered", "",
}})
status, resp := env.uploadEntries(t, client, "报名模板.xlsx", workbookBytes(t, f))
if status != http.StatusOK || !resp.OK {
t.Fatalf("照模板说明填的联队行应导入成功,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); len(result.Errors) != 0 {
t.Fatalf("不该有报错行:%v", result.Errors)
}
// 落库事实:新报名一条、两个单位各占一份(权重 1 = 均分),主单位是加号左边的 A。
var entryID int64
if err := env.ev.QueryRow(
`SELECT e.id FROM entries e JOIN athletes a ON a.id = e.athlete_id
WHERE e.event_id = ? AND a.name = '联队小张'`, ids.event).Scan(&entryID); err != nil {
t.Fatalf("查新报名: %v", err)
}
var primary, shares int
if err := env.ev.QueryRow(`SELECT team_id FROM entries WHERE id = ?`, entryID).Scan(&primary); err != nil {
t.Fatalf("查主单位: %v", err)
}
if int64(primary) != ids.teamA {
t.Errorf("主单位应是加号左边的 A(%d),得到 %d", ids.teamA, primary)
}
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entry_team_shares WHERE entry_id = ? AND points_share = 1 AND medal_share = 1`,
entryID).Scan(&shares); err != nil {
t.Fatalf("查份额: %v", err)
}
if shares != 2 {
t.Errorf("联队报名的份额行 = %d,期望 2(两个单位各一份、均分、奖牌各整枚)", shares)
}
}
@@ -0,0 +1,150 @@
package httpapi_test
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件是联队改动(devdocs/19)的「老数据零变化」硬门槛在**接口层**的一半:
// 四种形态(普通个人 / 纯团体 / 双计 / 团体报名项双通道)走真实的导入 · 发布 · 录名次接口,
// 再把积分榜、个人榜、报名 / 成绩 / 流水 / 通道名次四张表的原始内容拼成一份签名。
//
// 签名取自**联队实现之前**的代码(git archive HEAD 的修前副本上跑出来的),实现之后必须一字不差。
//
// 为什么特意**不含** GET /api/events/{id}/entries:那条读接口这次有意新增了 teamShares 字段
// (联队的成员单位与权重,前端据此渲染「A+B联队」),契约变了不该算回归;
// 它由 TestSingleUnitEntryKeepsWholeShare 单独钉住(普通报名下发一条独占份额)。
const legacyFormsGoldenAPI = "aa6cea376734cfc5f2334551dfa3095f31543babb10e00c2318b5146a8af42d2"
// TestLegacyFormsAPISnapshotUnchanged 对拍四种形态的榜单与落库事实。
func TestLegacyFormsAPISnapshotUnchanged(t *testing.T) {
env, ids, client := recorderWithSeed(t)
dump := legacyFormsAPIDump(t, env, client, ids)
// 防空转:签名里必须真的能看到四种形态的痕迹。少了这几处,对拍就成了「两份空文本相等」。
if n := strings.Count(dump, `"totalPoints"`); n < 3 {
t.Fatalf("积分榜签名里只有 %d 行(三个单位都该在榜上):\n%s", n, dump)
}
if !strings.Contains(dump, "t:") || !strings.Contains(dump, "e:") {
t.Fatalf("签名里应同时有「按单位录的名次」与「按报名项的名次」:\n%s", dump)
}
if !strings.Contains(dump, "points=") || !strings.Contains(dump, "subject_key") {
t.Fatalf("签名里应带上流水与通道名次的落库事实:\n%s", dump)
}
sum := sha256.Sum256([]byte(dump))
got := hex.EncodeToString(sum[:])
if got != legacyFormsGoldenAPI {
t.Fatalf("无联队的老数据在榜单 / 流水 / 名次上变了:\n签名 = %s\n期望 = %s",
got, legacyFormsGoldenAPI)
}
}
// legacyFormsAPIDump 造出四种形态并拼出签名文本。
func legacyFormsAPIDump(t *testing.T, env *testEnv, client *http.Client, ids seedIDs) string {
t.Helper()
// 第三个单位:颁奖牌榜上要有「没参加这个项目」的行,才能看出兜底行有没有变化。
teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active)
VALUES ('C', '第三分公司', '三司', 2, 1)`)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active)
VALUES ('王芳', '女', ?, 1)`, teamC)
// 形态 1 · 普通个人:seed 的 M100(individual + 个人成绩通道)。
env.exec(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamC, athC)
publishResults(t, env, client, ids.event, "legacy-individual",
map[string]any{"entryId": ids.entryA, "resultValue": 11.2, "resultText": "11.2", "unit": "秒", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.5, "resultText": "11.5", "unit": "秒", "status": "ok"},
map[string]any{"entryId": envEntryID(t, env, ids.event, athC), "resultValue": 12.0, "resultText": "12.0", "unit": "秒", "status": "ok"},
)
// 形态 2 · 纯团体:球类(match)+ 按单位录的团体名次。
ballEvent, ballCategory := seedMatchEvent(t, env, ids)
if status, resp := putTeamRanks(t, env, client, ballEvent, ballCategory, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
{"teamId": teamC, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
// 形态 3 · 双计:个人成绩通道(不计入单位总分)+ 团体聚合通道。
mixEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'MIX2', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
mixCategory := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, mixEvent)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, 'athlete', 'result', ?, 0, 0)`, mixEvent, profileIDByCode(t, env, "individual_top6"))
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 1)`, mixEvent, profileIDByCode(t, env, "team_top6"))
mixRows := make([]map[string]any, 0, 3)
for i, athleteID := range []int64{ids.athleteA, ids.athB, athC} {
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, mixEvent, mixCategory, []int64{ids.teamA, ids.teamB, teamC}[i], athleteID)
mixRows = append(mixRows, map[string]any{
"entryId": entryID, "resultValue": 10 + i, "resultText": fmt.Sprint(10 + i), "unit": "次", "status": "ok",
})
}
publishResults(t, env, client, mixEvent, "legacy-individual-team", mixRows...)
// 形态 4 · 团体报名项双通道:团体报名项(athlete_id 为空)+ 个人成绩通道 + 团体手工通道。
dualEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'DUAL', '混合配置试验', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
dualCategory := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, dualEvent)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, dualEvent, 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)`, dualEvent, profileIDByCode(t, env, "team_top6"))
dualRows := make([]map[string]any, 0, 3)
for i, teamID := range []int64{ids.teamA, ids.teamB, teamC} {
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, dualEvent, dualCategory, teamID)
dualRows = append(dualRows, map[string]any{
"entryId": entryID, "resultValue": 90 - 10*i, "resultText": fmt.Sprint(90 - 10*i), "unit": "次", "status": "ok",
})
}
publishResults(t, env, client, dualEvent, "legacy-dual", dualRows...)
// 团体名次按单位录(覆盖值优先于报名项自己的名次):这一支产出的名次没有报名项,
// subject_key 是「单位」,正是这次改动碰过的地方。
if status, resp := putTeamRanks(t, env, client, dualEvent, dualCategory, []map[string]any{
{"teamId": ids.teamA, "rank": 2},
{"teamId": ids.teamB, "rank": 1},
{"teamId": teamC, "rank": 3},
}); status != http.StatusOK {
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
}
return strings.Join([]string{
rawData(t, env, client, "/api/standings"),
rawData(t, env, client, "/api/standings/athletes"),
tableDump(t, env, `SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0) AS athlete_id, status
FROM entries ORDER BY id`),
tableDump(t, env, `SELECT id, event_id, entry_id, COALESCE(rank, 0) AS rank, COALESCE(result_value, 0) AS value,
status, state FROM results ORDER BY id`),
tableDump(t, env, `SELECT id, team_id, COALESCE(athlete_id, 0) AS athlete_id, COALESCE(event_id, 0) AS event_id,
COALESCE(entry_id, 0) AS entry_id, source_type, source_id, points, count_to_team, voided
FROM score_ledger ORDER BY id`),
tableDump(t, env, `SELECT id, event_id, category_id, channel_id, basis, scope, subject_key,
COALESCE(entry_id, 0) AS entry_id, COALESCE(team_id, 0) AS team_id, rank, points
FROM channel_ranks ORDER BY id`),
}, "\n---\n") + "\n"
}
// envEntryID 取某小项里某运动员的报名项 id。
func envEntryID(t *testing.T, env *testEnv, eventID, athleteID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(
`SELECT id FROM entries WHERE event_id = ? AND athlete_id = ?`, eventID, athleteID).Scan(&id); err != nil {
t.Fatalf("读报名项: %v", err)
}
return id
}
+8
View File
@@ -45,6 +45,14 @@ type entryHeatPayload struct {
RequestID string `json:"requestId"`
}
// entryTeamSharesPayload 是 PUT /api/entries/{id}/team-shares 的请求体:整组替换一条报名的份额。
// 不传重量字段(weight 缺省 1)就是均分;medal 缺省 true = 这枚奖牌整枚记给该单位。
// 必须包含主单位(entries.team_id),且每个单位只能出现一次——校验见 validateEntryShares。
type entryTeamSharesPayload struct {
TeamShares []entryTeamShareDTO `json:"teamShares"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
+12 -1
View File
@@ -832,7 +832,18 @@ func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, er
}
out = append(out, e)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 份额单独查一次再挂上:一个报名可能有多条(联队),join 进上面那条 SQL 会把报名行乘开。
shares, err := s.loadEntrySharesDTOs(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName)
}
return out, nil
}
// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。
+15 -2
View File
@@ -674,11 +674,19 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
if err != nil {
return entryImportOutcome{}, err
}
if _, err := s.ev.ExecContext(ctx,
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil {
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status)
if err != nil {
return entryImportOutcome{}, err
}
newID, err := res.LastInsertId()
if err != nil {
return entryImportOutcome{}, internal(err)
}
if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err
}
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
@@ -705,6 +713,11 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
status, existing); err != nil {
return entryImportOutcome{}, err
}
// 份额整组重写:同一行重传要幂等,改了写法(联队改回单单位、换成员)也要跟着变,
// 只增不改会把上一次的配置一直用下去(见 replaceEntryShares)。
if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{
EventID: in.EventID,
CategoryID: in.CategoryID,
+15 -6
View File
@@ -619,17 +619,26 @@ func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedRes
v := value.Float64
rr.ResultValue = &v
}
// 归属份额沿用「主单位独占 100%」:team_id 为空(理论上不会)时不造份额,
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 归属份额按报名项的配置补:channel_ranks 只存名次与主体(entry_id 有外键、可回查),
// 份额是报名项上的配置,引擎与榜单两边都必须从同一处读——
// 没有报名项的名次(聚合 / 团体手工)与没配份额的老数据都退回「主单位独占 100%」,
// 与 domain.ResolveShares 同一口径(老数据因此一分一牌都不变)。
shares, err := loadEntryTeamSharesQ(ctx, q, 0)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// breakdownRankExpr 取一条单位流水的名次(devdocs 17 §11.3):优先按流水对应的通道与主体
+14 -2
View File
@@ -236,7 +236,18 @@ func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
e.MemberNames = parseNames(memberNames)
out = append(out, e)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 联队份额随报名一起进引擎:引擎不看库,取分只认 Entry.TeamShares。
shares, err := loadEntryTeamSharesQ(ctx, q, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = shares[out[i].ID]
}
return out, nil
}
func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) {
@@ -503,7 +514,8 @@ func hasSubjectChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, er
// 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。
// SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。
// AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels
// 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。
// 得到的,不落这张表;shares 同样不进表——读取侧按 entry_id 回查报名份额(entry_team_shares),
// 回查不到(没有报名项的名次、没配份额的老数据)与引擎一样退回「主单位独占 100%」。
func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error {
if categoryID > 0 {
if _, err := tx.ExecContext(ctx,
+3
View File
@@ -276,6 +276,9 @@ func (s *server) routes(mux *http.ServeMux) {
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
// 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。
// 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。
mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
+53 -4
View File
@@ -330,15 +330,64 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
v := value.Float64
rr.ResultValue = &v
}
// 与 REST 同口径:份额只有「主单位独占 100%」一种,team_id 为空时不造份额。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 与 REST 的 buildRanked、导出的 loadRanked 同口径:联队份额按报名项配置补,
// 没有报名项的名次与没配份额的老数据退回「主单位独占 100%」。
// 大屏的奖牌榜读的就是这里——少补这一趟,联队的名次在大屏上只记给主单位。
shares, err := loadEntryTeamShares(ctx, tx)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。
//
// 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库
// 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。
func loadEntryTeamShares(ctx context.Context, tx *sql.Tx) (map[int64][]domain.TeamShare, error) {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return nil, fmt.Errorf("查询报名份额表: %w", err)
}
if n == 0 {
return nil, nil
}
rows, err := tx.QueryContext(ctx, `SELECT entry_id, team_id, points_share, medal_share
FROM entry_team_shares
ORDER BY entry_id, sort_order, team_id`)
if err != nil {
return nil, fmt.Errorf("读取报名份额: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, fmt.Errorf("扫描报名份额: %w", err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
@@ -0,0 +1,48 @@
package realtime
import (
"context"
"fmt"
"testing"
)
// 大屏(WS 快照)是联队奖牌的第三条读路径(另有 REST 与 xlsx 导出)。
// 它读 channel_ranks 之后要按报名项的份额补归属,少了这一步,联队拿到的金牌在大屏上
// 只会记给主单位,B 单位明明一起拿了金牌却榜上无名。
//
// 这里造一条「联队报名 + 团体名次」的最小现场:报名挂在 A 头上、份额是 A+B,
// 名次 1(金牌)落在这条报名上,断言两个单位各记 1 枚。
func TestSnapshotJointTeamMedalsGoToEveryMember(t *testing.T) {
env := newTestEnv(t)
db := env.db
teamA := insertTeam(t, db, "A", "第一分公司", "一司")
teamB := insertTeam(t, db, "B", "第二分公司", "二司")
eventID := insertEvent(t, db, "BALL", "篮球")
categoryID := insertCategory(t, db, eventID, "男子")
entryID := insertEntry(t, db, eventID, categoryID, teamA, 0)
channelID := insertChannel(t, db, eventID, "team")
mustExec(t, db, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, team_id, rank, points, status)
VALUES (?, ?, ?, 'manual', 'team', ?, ?, ?, 1, 0, 'ok')`,
eventID, categoryID, channelID, fmt.Sprintf("e:%d", entryID), entryID, teamA)
// 联队份额:A、B 均分、奖牌各记整枚(写侧就是这么落的,见 entry_team_shares 的迁移与 httpapi)。
mustExec(t, db, `INSERT INTO entry_team_shares (entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 1)`, entryID, teamA, entryID, teamB)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
gold := make(map[int64]int, len(snap.Standings))
for _, row := range snap.Standings {
gold[row.TeamID] = row.Gold
}
if gold[teamA] != 1 {
t.Fatalf("主单位 A 应记 1 枚金牌,得到 %d(完整:%+v)", gold[teamA], snap.Standings)
}
if gold[teamB] != 1 {
t.Fatalf("成员单位 B 也应记 1 枚金牌(不是 0.5 枚、也不是 0 枚),得到 %d(完整:%+v)",
gold[teamB], snap.Standings)
}
}
@@ -0,0 +1,37 @@
-- 联队 + 份额分成(devdocs/19 的实现)。
--
-- 背景:现场有两个单位因参赛人数不足合并成一个单位参赛。不能新建「联队单位」——榜单会多出
-- 一个单位,而且联队拿到的分与奖牌分不到 A、B 头上。所以把「一条报名归哪些单位、各占多少」
-- 单独放一张表,entries.team_id 保留为**主单位**(既有查询、唯一键、榜单、导入都还认它)。
--
-- 两个口径必须分开表达(devdocs/19 第三节的拍板结果):
-- points_share:积分份额,可配、默认均分。引擎按权重之和归一(1+1 就是各一半),
-- 奇数分允许拆出小数(团体第 1 名 15 分 → 两个单位各 7.5 分)。
-- medal_share :奖牌是否**整枚**记给本单位。联队拿到的金牌两个单位各记 1 枚(不是各 0.5 枚),
-- 所以它只有 0/1 两态,不跟着 points_share 拆成小数枚。
-- 两个口径用一个数表达的话,7.5 分与「半枚金牌」会被迫绑在一起,奖牌榜立刻就错。
--
-- 老数据行为零变化:下面把每条既有报名回填成「主单位独占 100%」(权重 1 = 归一后仍是 100%,
-- medal_share 1 = 整枚),于是引擎、榜单、导出读老库得到的结果与升级前一模一样。
-- 回填用 INSERT OR IGNORE:重复执行(schema_migrations 少一行被补跑)不会覆盖已经配好的联队份额,
-- 只会给还没有份额的报名补一条独占。
--
-- 写在读侧的一处约定:份额表里**没有行**等价于「主单位独占 100%」(domain.ResolveShares)。
-- 回填让老数据以后者为准,两条路都给同一个结果;写侧永远保持「至少一条」。
CREATE TABLE IF NOT EXISTS entry_team_shares (
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES teams(id),
points_share REAL NOT NULL DEFAULT 1 CHECK (points_share > 0),
medal_share INTEGER NOT NULL DEFAULT 1 CHECK (medal_share IN (0, 1)),
-- 展示与流水顺序都用它:write 侧按请求给的顺序编号,导入按 team_code 里 A+B 的书写顺序。
-- 主单位建议排第一(榜单与导出据此拼「A+B联队」),但引擎不依赖顺序取分(份额之和归一)。
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (entry_id, team_id)
);
-- 不另建 entry_id 索引:主键 (entry_id, team_id) 的索引已经以 entry_id 打头,
-- 读侧的「按报名项批量取份额」走的正是它(多一个索引只会在每次写份额时多付一次代价)。
INSERT OR IGNORE INTO entry_team_shares (entry_id, team_id, points_share, medal_share, sort_order)
SELECT id, team_id, 1, 1, 0 FROM entries;
@@ -0,0 +1,176 @@
package event
import (
"database/sql"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0024:联队 + 份额分成(entry_team_shares)。
//
// 三条底线:
// - 老库升级后每条既有报名都拿到一条「主单位独占 100%」(权重 1、整枚奖牌),
// 于是引擎与榜单对老数据的行为一分不差;
// - 其它行(尤其是 entries.team_id 这个主单位)一行不动,也没有任何报名被删;
// - 幂等:重复执行(含把这条 SQL 的正文重放一遍)不会覆盖已经配好的联队份额。
func TestMigration0024EntryTeamShares(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0024") // 回到「升级前」:库结构到 0023 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
`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, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`,
// 个人报名(athlete_id 非空)与团体报名(athlete_id 为空)都要被回填。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, NULL, '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_team_shares`); err == nil {
t.Fatal("升级前不应存在 entry_team_shares 表")
} else if !strings.Contains(err.Error(), "entry_team_shares") {
t.Fatalf("查询报错应点明缺表 entry_team_shares,实际 %v", err)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 回填:每条报名一条「主单位独占 100%」,主单位就是 entries.team_id。
rows, err := db.Query(`SELECT e.id, e.team_id, s.team_id, s.points_share, s.medal_share, s.sort_order
FROM entries e JOIN entry_team_shares s ON s.entry_id = e.id
ORDER BY e.id`)
if err != nil {
t.Fatalf("读回填结果: %v", err)
}
defer func() { _ = rows.Close() }()
seen := 0
for rows.Next() {
var (
entryID, primary, shareTeam int64
pointsShare float64
medalShare, sortOrder int
)
if err := rows.Scan(&entryID, &primary, &shareTeam, &pointsShare, &medalShare, &sortOrder); err != nil {
t.Fatalf("扫描回填结果: %v", err)
}
if shareTeam != primary {
t.Errorf("报名 %d 回填出来的份额单位 = %d,期望主单位 %d", entryID, shareTeam, primary)
}
if pointsShare != 1 || medalShare != 1 || sortOrder != 0 {
t.Errorf("报名 %d 的独占份额应为 points_share=1 medal_share=1 sort_order=0,得到 %v/%d/%d",
entryID, pointsShare, medalShare, sortOrder)
}
seen++
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历回填结果: %v", err)
}
if seen != 2 {
t.Fatalf("回填行数 = %d,期望 2(每条既有报名一条)", seen)
}
// entries 一行不少、主单位一列不改(回填只增一张表)。
if got := countRows(t, db, "entries"); got != 2 {
t.Fatalf("升级后报名数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id NOT IN (SELECT id FROM teams)`); got != 0 {
t.Errorf("条目里出现了不存在的单位: %d 条", got)
}
// 约束:权重必须为正、奖牌份数只有 0/1、同一报名同一单位只能一条。
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share) VALUES (1, 2, 0)`); err == nil {
t.Error("points_share = 0 应被 CHECK 拒绝(份额为零等于没写)")
}
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, medal_share) VALUES (1, 2, 2)`); err == nil {
t.Error("medal_share = 2 应被 CHECK 拒绝(奖牌只有整枚/不记两态)")
}
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id) VALUES (1, 1)`); err == nil {
t.Error("同一报名同一单位重复应被主键拒绝")
}
// 联队:给报名 1 再加一条成员单位 B(这就是升级后写侧要写的东西)。
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 2, 1, 1, 1)`); err != nil {
t.Fatalf("写联队份额应成功: %v", err)
}
// 幂等:再跑一次迁移,行数与内容都不变。
before := dumpShares(t, db)
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
if got := dumpShares(t, db); got != before {
t.Fatalf("重复迁移改动了份额:\n之前 %s\n之后 %s", before, got)
}
// 迁移被重放(schema_migrations 少一行)时:只补缺的行,绝不覆盖已配好的联队份额。
if _, err := db.Exec(readMigration(t, "0024")); err != nil {
t.Fatalf("重放 0024: %v", err)
}
if got := dumpShares(t, db); got != before {
t.Fatalf("重放 0024 改动了份额:\n之前 %s\n之后 %s", before, got)
}
// 报名被删时份额跟着走(ON DELETE CASCADE),不留孤儿。
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_team_shares WHERE entry_id = 2`); 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)
}
}
// dumpShares 把份额表按稳定顺序导成文本。
func dumpShares(t *testing.T, db *sql.DB) string {
t.Helper()
var b strings.Builder
rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share, sort_order
FROM entry_team_shares ORDER BY entry_id, team_id`)
if err != nil {
t.Fatalf("读份额: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
entryID, teamID int64
pointsShare float64
medalShare, sortOrder int
)
if err := rows.Scan(&entryID, &teamID, &pointsShare, &medalShare, &sortOrder); err != nil {
t.Fatalf("扫描份额: %v", err)
}
fmt.Fprintf(&b, "%d:%d:%v:%d:%d\n", entryID, teamID, pointsShare, medalShare, sortOrder)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历份额: %v", err)
}
return b.String()
}
+69 -6
View File
@@ -1,4 +1,4 @@
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam, splitSharePoints } from '@/shared'
import type {
AvgMode,
ChannelBasis,
@@ -30,6 +30,13 @@ export interface ScorableRow {
rank: number | null
status: ResultStatus
isTie: boolean
/**
* 归属份额(联队,devdocs/19);普通报名不传 = 主单位独占 100%。
*
* 引擎侧按份额把分拆给各成员单位(scoring.go 的 ledgerFromRanked),录入页的实时预览要同口径,
* 否则联队那条会显示成「A +15」,而发布后 A、B 各 +7.5。
*/
shares?: readonly { teamId: number; weight: number }[]
}
const EPSILON = 1e-9
@@ -300,17 +307,50 @@ function rankOnlyChannel(event: Event): ScoreChannel {
}
}
/** 一行折算到某个单位的贡献(联队时一行会有多条)。 */
export interface RowTeamShare {
teamId: number
points: number
}
export interface EventScoreResult {
/** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
rowPoints: Map<number, number>
/** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
rowTeamPoints: Map<number, number>
/**
* 上一行贡献按份额拆到各成员单位的结果,普通报名只有一条(主单位)。
*
* 「已发布的分要从单位总分里扣掉」时不能按 `row.teamId` 累加 rowTeamPoints:
* 联队那条的整份分会全记在主单位头上,扣出来的差值在 B 单位那边是错的。
*/
rowTeamShares: Map<number, RowTeamShare[]>
/** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
ranks: Map<number, number | null>
/** 按单位聚合的得分增量,用于预览面板。 */
teamPoints: Map<number, number>
}
/**
* 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。
*
* 聚合名次(entryId = 0,主体是单位或运动员)与没有配份额的行一律「主单位独占」:
* 引擎那边聚合并联队不拆份额(名次不对应报名项,读取侧回查不到份额),预览必须同口径,
* 否则会出现「预览按份额拆、发布后整份记给主单位」。
*/
function rowTeamShares(
entryId: number,
teamId: number,
shares: readonly { teamId: number; weight: number }[] | undefined,
contribution: number,
): RowTeamShare[] {
if (entryId <= 0 || shares === undefined || shares.length < 2) {
return [{ teamId, points: contribution }]
}
const parts = splitSharePoints(contribution, shares)
return shares.map((share, index) => ({ teamId: share.teamId, points: parts[index] ?? 0 }))
}
export interface ScoreContext {
event: Event
category: EventCategory | null
@@ -335,10 +375,16 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
const rowPoints = new Map<number, number>()
const rowTeamPoints = new Map<number, number>()
const rowShares = new Map<number, RowTeamShare[]>()
const ranks = new Map<number, number | null>()
const rankScope = new Map<number, ChannelScope>()
const teamPoints = new Map<number, number>()
const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>()
for (const row of rows) {
if (row.shares !== undefined && row.shares.length > 0) sharesByEntry.set(row.entryId, row.shares)
}
for (const channel of channels) {
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
@@ -346,11 +392,27 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
const countToTeam = normalizeCountToTeam(channel.countToTeam)
for (const row of ranked) {
// 个人通道的分要按个人→单位系数折算,团体通道的分本来就是单位的分。
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
const parts = countToTeam
? rowTeamShares(row.entryId, row.teamId, sharesByEntry.get(row.entryId), teamContribution)
: []
if (row.entryId > 0) {
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
if (countToTeam) {
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
rowTeamPoints.set(
row.entryId,
(rowTeamPoints.get(row.entryId) ?? 0) + teamContribution,
)
// 多通道叠加时按单位合并,而不是按下标对齐:渠道顺序一变,按下标就会串位。
const merged = new Map((rowShares.get(row.entryId) ?? []).map((part) => [part.teamId, part.points]))
for (const part of parts) {
merged.set(part.teamId, (merged.get(part.teamId) ?? 0) + part.points)
}
rowShares.set(
row.entryId,
[...merged].map(([teamId, points]) => ({ teamId, points })),
)
}
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
const current = rankScope.get(row.entryId)
@@ -360,13 +422,14 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
}
}
if (!countToTeam) continue
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
for (const part of parts) {
teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points)
}
}
}
for (const row of rows) {
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
}
return { rowPoints, rowTeamPoints, ranks, teamPoints }
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints }
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { MAX_JOINT_TEAM_MEMBERS } from '@/shared'
import type { ConflictAction, EntryImportResult, ImportConflict, SameNameNewPerson } from '@/shared'
import MxIcon from '../MxIcon.vue'
import EntryPicker from './EntryPicker.vue'
@@ -7,6 +8,7 @@ import { useBaseData } from './useBaseData'
import { entryImportSizeError } from './importLimits'
import { useToastStore } from '../../stores/toast'
import {
COLUMN_ALIASES,
buildTemplateCsv,
conflictDiffs,
conflictLabel,
@@ -173,6 +175,14 @@ async function runImport(): Promise<void> {
const HEADER_EN = templateHeader('en').join(' / ')
const HEADER_ZH = templateHeader('zh').join(' / ')
/**
* 联队说明里的两个词:单位编码列的中文写法、成员上限。
*
* 两个都不写死:列名取自与服务端同源的别名表,上限取自共享层(与服务端 maxJointTeamMembers 同一个数),
* 别名或上限改了说明跟着变,不会出现「说明写 5、服务端拦在第 3 个」这种对不上。
*/
const UNIT_CODE_LABEL = COLUMN_ALIASES.team_code[0]
function downloadTemplate(language: TemplateLanguage): void {
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
const blob = new Blob([`\uFEFF${buildTemplateCsv(language)}\n`], { type: 'text/csv;charset=utf-8' })
@@ -281,6 +291,12 @@ function saveBlob(blob: Blob, filename: string): void {
id_card、gender、age、heat 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
<br />
<strong>联队(两个单位合并参赛)</strong>:把
<strong>{{ UNIT_CODE_LABEL }}</strong> 那一格写成 <strong>A+B</strong> —— 加号左边的是<strong>主单位</strong>,
分数与奖牌按份额记给两个单位(默认均分,奖牌各记一枚),全角「+」与两侧空格都认。
最多 {{ MAX_JOINT_TEAM_MEMBERS }} 个单位;<code>A+</code>、<code>+B</code>、<code>A+A</code> 这些写法会被拦下,
并逐行告诉你是哪一格写错了。要改成别的份额(例如 2:1)到「成绩录入」页点该行的<strong>单位那一格</strong>调整。
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
两者都没命中才按「姓名 + 单位」找——单位内恰好一人且身份字段对得上(或有一边没填)就命中并把证件号 / 号码布回填进档案;
恰好一人但两边的证件号(或号码布)对不上,按「同名的另一个人」新建档案(结果里会单独列出,不报错);
+402 -4
View File
@@ -5,7 +5,11 @@ import {
eventInputOf,
formatPoints,
hasAggregateChannel,
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
parseResultValue,
shareText,
type AvgMode,
type DraftResultInput,
type EntryStatus,
@@ -14,6 +18,7 @@ import {
type ResultState,
type ResultStatus,
type TeamRanksView,
type TeamShareView,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -39,6 +44,19 @@ import {
type TeamRankRow,
} from './teamRanks'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
addShareDraftTeam,
removeShareDraftTeam,
selectableShareTeams,
setShareDraftWeight,
shareDraftDirty,
shareDraftIssues,
shareDraftOf,
shareDraftPayload,
shareDraftView,
submitEntryTeamShares,
type ShareDraftRow,
} from './entryShares'
import {
entryBatchOutcome,
entryBatchSizeError,
@@ -92,6 +110,13 @@ interface EditableRow {
athleteId: number | null
teamName: string
teamShort: string
/**
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
*
* 表格的「单位」列与实时积分预览都读它——联队那条的分要拆给各成员单位,
* 只看 teamId 会把整份分记在主单位头上。
*/
teamShares: TeamShareView[]
athleteName: string
memberNames: string[] | null
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
@@ -352,6 +377,8 @@ function scorableOf(rows: EditableRow[]): ScorableRow[] {
rank: row.rank,
status: row.status,
isTie: row.isTie,
// 联队份额随行喂给预览算分:单单位时它就是主单位独占,与升级前同口径。
shares: row.teamShares.map((share) => ({ teamId: share.teamId, weight: share.weight })),
}))
}
@@ -386,10 +413,13 @@ const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) =
*/
const publishedContribution = computed(() => {
const map = new Map<number, number>()
const rowTeamPoints = publishedScore.value?.rowTeamPoints
const rowTeamShares = publishedScore.value?.rowTeamShares
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
for (const part of rowTeamShares?.get(row.entryId) ?? []) {
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
}
}
return map
})
@@ -752,14 +782,22 @@ function snapshotOf(entry: EntryWithResult): string {
function toRow(entry: EntryWithResult): EditableRow {
const result = entry.result
const teamName = entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位'
const row: EditableRow = {
entryId: entry.id,
categoryId: entry.categoryId,
bib: entry.bib,
teamId: entry.teamId,
athleteId: entry.athleteId,
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
teamName,
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
teamShares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId,
weight: 1,
teamCode: entry.teamCode ?? data.teamById(entry.teamId)?.code ?? '',
teamName,
}),
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
heat: entry.heat,
@@ -956,6 +994,148 @@ async function onSupplementSubmitted(eventId: number, categoryId: number | null)
}
}
// ── 成员单位与份额(联队) ────────────────────────────────────────────────────
/**
* 份额编辑器(devdocs/19)。
*
* 打开时把这条报名**当前**的成员单位摆出来:单单位报名看到的就是孤零零一个主单位、
* 权重留空,什么都不改直接关掉等于没动过——常规报名流程一点没被碰到。
* 需要时在弹窗里加单位、填权重(留空 = 均分),保存后服务端当场重算本组并推榜。
*
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
* 发出去(服务端会照着做,只是白重算一遍本组)。
*/
interface ShareEditor {
entryId: number
/** 展示用的报名身份:运动员 / 队 + 号码布,让操作员确认改的是哪一条。 */
label: string
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
primaryTeamId: number
primaryName: string
original: ShareDraftRow[]
draft: ShareDraftRow[]
saving: boolean
error: string | null
}
const shareEditor = ref<ShareEditor | null>(null)
const pendingShareTeamId = ref('')
const shareIssues = computed(() => {
const editor = shareEditor.value
if (editor === null) return []
return shareDraftIssues(editor.draft, editor.primaryTeamId, editor.primaryName)
})
const shareChanged = computed(() => {
const editor = shareEditor.value
return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
})
const shareView = computed(() => {
const editor = shareEditor.value
return editor === null ? [] : shareDraftView(editor.draft)
})
/** 比例预览:`第一分公司 67% · 第二分公司 33%`,保存前就看得到分怎么拆。 */
const shareRatioText = computed(() => shareView.value.map(shareText).join(' · '))
/** 还能加进来的单位:不在成员里的都行(已停用的可以,假删除的不要)。 */
const shareCandidates = computed(() => {
const editor = shareEditor.value
return editor === null ? [] : selectableShareTeams(data.teams, editor.draft)
})
const canSaveShares = computed(
() =>
canWrite.value &&
shareEditor.value !== null &&
!shareEditor.value.saving &&
shareChanged.value &&
shareIssues.value.length === 0,
)
function openShareEditor(row: EditableRow): void {
pendingShareTeamId.value = ''
shareEditor.value = {
entryId: row.entryId,
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
primaryTeamId: row.teamId,
primaryName: row.teamName,
original: shareDraftOf(row),
draft: shareDraftOf(row),
saving: false,
error: null,
}
}
function closeShareEditor(): void {
shareEditor.value = null
pendingShareTeamId.value = ''
}
function addShareMember(): void {
const editor = shareEditor.value
if (editor === null) return
const team = data.teams.find((item) => String(item.id) === pendingShareTeamId.value)
if (team === undefined) return
editor.draft = addShareDraftTeam(editor.draft, team)
}
function dropShareMember(teamId: number): void {
const editor = shareEditor.value
if (editor === null || teamId === editor.primaryTeamId) return
editor.draft = removeShareDraftTeam(editor.draft, teamId)
}
function editShareWeight(teamId: number, event: InputEvent): void {
const editor = shareEditor.value
if (editor === null) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
}
/**
* 保存份额:整组替换一条报名的成员单位。
*
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉,
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。
*/
async function saveShareEditor(): Promise<void> {
const editor = shareEditor.value
if (editor === null || !canSaveShares.value) return
if (data.isMock) {
const notice = sampleDataBlockedNotice('保存成员单位')
toast.error(notice.title, notice.detail)
return
}
editor.saving = true
editor.error = null
const payload = shareDraftPayload(editor.draft)
try {
const outcome = await submitEntryTeamShares(editor.entryId, payload)
const shares = outcome.teamShares ?? payload
const row = allRows.value.find((item) => item.entryId === editor.entryId)
if (row !== undefined) {
const fallbackName = row.teamName
row.teamShares = normalizeTeamShares(shares, {
teamId: row.teamId,
weight: 1,
teamCode: row.teamShares[0]?.teamCode ?? '',
teamName: fallbackName,
})
}
closeShareEditor()
void data.refreshStandings()
toast.success(
'成员单位已保存',
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位',
)
} catch (caught) {
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
} finally {
editor.saving = false
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
@@ -2137,7 +2317,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span :class="row.heat === null ? 'heat-none' : 'heat-tag'">{{ heatLabel(row.heat) }}</span>
</td>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
<!--
单位格兼作「成员单位与份额」的入口:点一下打开联队弹窗(默认就是当前这一个
单位,什么都不改等于没动过)。普通报名的文案与改造前逐字一样,联队才多一行
成员名 + 份额比例——两个单位各占多少分,在表格上就要看得见。
-->
<span
v-if="!isJointTeam(row.teamShares)"
class="team-cell is-editable"
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`"
@click="openShareEditor(row)"
>{{ row.teamShort || row.teamName }}</span>
<span
v-else
class="team-cell is-editable is-joint"
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`"
@click="openShareEditor(row)"
>
<span class="mx-badge mx-badge-info">联队</span>
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
{{ shareText(member) }}
</span>
</span>
</td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
@@ -2454,6 +2655,114 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div>
</div>
<!--
成员单位与份额(联队,devdocs/19):外壳照页面里既有的弹窗写法自己套一层。
默认摆出来的就是这条报名当前的份额——单单位报名打开后只有一行主单位、权重留空,
不点「添加」「保存」的话一个请求都不会发出去,常规报名流程不受影响。
-->
<div v-if="shareEditor !== null" class="mx-modal-overlay open" @click.self="closeShareEditor">
<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="closeShareEditor">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="share-hint">
一条报名可以挂<strong>多个单位</strong>(两个单位因人数不足合并参赛时用):这条成绩的积分按份额
拆给各成员单位,奖牌<strong>各记一枚</strong>;分数平均拆、接受小数(团体第 1 名 15 分 → 两个单位各 7.5 分)。
份额是<strong>相对权重</strong>:都留空就是均分(各 50%),填 2 与 1 就是 67% 与 33%。
</p>
<p class="bd-note">
正在修改:{{ shareEditor.label }};主单位「{{ shareEditor.primaryName }}」不能去掉。
</p>
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
<div v-if="!canWrite" class="share-readonly">
<MxIcon name="lock" :size="14" />
<span>{{ auth.readOnly ? '当前为访客只读视图,成员单位只能查看。' : '服务端未连接,暂时不能修改成员单位。' }}</span>
</div>
<ul class="share-list">
<li v-for="member in shareEditor.draft" :key="member.teamId" class="share-item">
<span class="share-code mono">{{ member.teamCode || '—' }}</span>
<span class="share-name">{{ member.teamName }}</span>
<span v-if="member.teamId === shareEditor.primaryTeamId" class="mx-badge mx-badge-neutral">主单位</span>
<input
class="mx-cell-input share-weight mono"
type="number"
min="0"
step="0.5"
placeholder="留空=1"
:value="member.weightText"
:disabled="!canWrite || shareEditor.saving"
title="份额是相对权重:留空或填 0 都按 1 算(均分)"
@input="editShareWeight(member.teamId, $event)"
/>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="member.teamId === shareEditor.primaryTeamId || shareEditor.saving"
:title="
member.teamId === shareEditor.primaryTeamId
? '主单位不能去掉:报名挂在它名下,换主单位要重新建报名'
: '把这个单位从这个联队里去掉'
"
@click="dropShareMember(member.teamId)"
>
移除
</button>
</li>
</ul>
<div class="share-add">
<select
v-model="pendingShareTeamId"
class="mx-cell-input bd-select"
:disabled="!canWrite || shareEditor.saving || shareCandidates.length === 0"
>
<option value="">添加成员单位…</option>
<option v-for="team in shareCandidates" :key="team.id" :value="String(team.id)">
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
</option>
</select>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="pendingShareTeamId === '' || shareEditor.saving"
@click="addShareMember"
>
添加
</button>
</div>
<p class="share-ratio">
当前比例:<strong>{{ shareRatioText }}</strong>
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
</p>
<ul v-if="shareIssues.length > 0" class="issue-list">
<li v-for="(message, index) in shareIssues" :key="index" class="error">
<MxIcon name="warning" :size="14" />
<span>{{ message }}</span>
</li>
</ul>
<div v-if="shareEditor.error !== null" class="mx-badge mx-badge-error share-error">
{{ shareEditor.error }}
</div>
<p class="bd-note">
保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的,
点「关闭」不会发出任何请求。
</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor">
{{ shareChanged ? '放弃修改' : '关闭' }}
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
<MxIcon name="check" :size="14" />
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }}
</button>
</div>
</div>
</div>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
@@ -2822,6 +3131,95 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-cell {
font-weight: 600;
}
/* 单位格兼作「成员单位与份额」的入口:悬停给出手型与底色,点开的是同一个弹窗。
文案本身不动——单单位报名的这一格与改造前逐字一样。 */
.team-cell.is-editable {
display: inline-block;
max-width: 100%;
border-radius: var(--mx-r-sm);
cursor: pointer;
}
.team-cell.is-editable:hover {
background: var(--mx-primary-glow);
}
/* 联队:单位名 + 各自份额逐条列出(名字写全,比例看得见),不再压成一行。 */
.team-cell.is-joint {
display: flex;
flex-direction: column;
gap: 2px;
font-weight: 600;
}
.joint-member {
font-weight: 500;
font-size: 12px;
color: var(--mx-t2);
}
/* ── 成员单位与份额弹窗 ── */
.share-hint {
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--mx-t2);
}
.share-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.share-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.share-code {
min-width: 44px;
font-size: 12px;
color: var(--mx-t3);
}
.share-name {
flex: 1;
min-width: 0;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-weight {
width: 96px;
text-align: center;
}
.share-add {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.share-ratio {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
color: var(--mx-t2);
}
.share-readonly {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 8px;
font-size: 12px;
color: var(--mx-amber);
}
.share-error {
display: block;
margin-bottom: 8px;
padding: 10px 12px;
line-height: 1.5;
}
/*
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
+47 -6
View File
@@ -3,10 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatPoints,
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
type TeamShareView,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -25,6 +29,8 @@ interface DraftRow {
teamId: number
teamName: string
teamShort: string
/** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */
teamShares: TeamShareView[]
athleteName: string
memberNames: string[] | null
resultText: string
@@ -211,6 +217,13 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
// 联队份额:预览的分要按份额拆到各成员单位(单单位时就是主单位独占)。
shares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId,
weight: 1,
teamCode: entry.teamCode,
teamName: entry.teamName,
}).map((share) => ({ teamId: share.teamId, weight: share.weight })),
}))
const context: ScoreContext = {
event,
@@ -230,14 +243,14 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
)
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
// rowTeamShares 已按通道 scope 折算好个人→单位系数、并按份额拆到各成员单位,
// 与服务端流水口径一致;按 teamId 累加 rowTeamPoints 会把联队的整份分记在主单位头上。
const published = new Map<number, number>()
for (const entry of scoped) {
if (entry.result?.state !== 'published') continue
published.set(
entry.teamId,
(published.get(entry.teamId) ?? 0) + round(publishedScore.rowTeamPoints.get(entry.id) ?? 0),
)
for (const part of publishedScore.rowTeamShares.get(entry.id) ?? []) {
published.set(part.teamId, (published.get(part.teamId) ?? 0) + round(part.points))
}
}
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
@@ -254,6 +267,12 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
teamId: entry.teamId,
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
teamShort: entry.shortName ?? '',
teamShares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId,
weight: 1,
teamCode: entry.teamCode,
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
}),
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
resultText: entry.result?.resultText ?? '',
@@ -609,7 +628,18 @@ watch(
</span>
</td>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
<!-- 联队:成员单位名都写全(复核时要知道这一条其实是几个单位合并报的)。 -->
<span
v-if="isJointTeam(row.teamShares)"
class="team-cell is-joint"
:title="jointTeamLabel(row.teamShares)"
>
<span class="mx-badge mx-badge-info">联队</span>
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
{{ member.teamName }} {{ member.percent }}%
</span>
</span>
<span v-else class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td class="mono">{{ row.resultText || '—' }}</td>
@@ -788,6 +818,17 @@ watch(
.team-cell {
font-weight: 600;
}
/* 联队:成员单位名 + 各自份额逐条列出,不压成一行。 */
.team-cell.is-joint {
display: flex;
flex-direction: column;
gap: 2px;
}
.joint-member {
font-weight: 500;
font-size: 12px;
color: var(--mx-t2);
}
.center {
text-align: center;
}
+292
View File
@@ -0,0 +1,292 @@
import { ApiError } from '@/shared'
import type { ApiErrorCode, Entry, EntryTeamShare, Team } from '@/shared'
import { MAX_JOINT_TEAM_MEMBERS, normalizeTeamShares, shareWeightOf } from '@/shared'
import type { TeamShareView } from '@/shared'
export { MAX_JOINT_TEAM_MEMBERS }
/**
* 「成员单位与份额」(联队)的编辑模型与写请求,见 devdocs/19。
*
* 网络层直接 fetch 而不是走 adminApi:与 entryBatch.ts 同一套理由(请求构造与校验收在一个模块里,
* 能直接单测),CSRF 与 X-Request-Id 的补法也必须与 shared/api.ts 的写请求保持一致,
* 否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
*
* 编辑模型本身只有一件事:**默认一个单位**。打开编辑器时把这条报名当前的份额原样摆出来,
* 单单位报名看到的就是它现在的样子(一个单位、权重留空),不加人不改权重就与现在完全一样。
*/
export function entryTeamSharesPath(entryId: number): string {
return `/api/entries/${entryId}/team-shares`
}
/**
* 编辑器里的一行:一个成员单位 + 权重输入框的原文。
*
* 权重存**文本**而不是数字:输入框里「空」是有意义的(不填 = 按 1 算 = 均分),
* 存成数字会被 Number('') = 0 抹掉这个区别,用户就看不到自己到底填没填。
*/
export interface ShareDraftRow {
teamId: number
teamCode: string
teamName: string
weightText: string
}
/** 报名行里够拼出编辑器初值的那几列。 */
export interface ShareSourceLike {
teamId: number
teamCode?: string
teamName?: string
teamShares?: readonly EntryTeamShare[]
}
function codeOf(team: Pick<Team, 'code'>): string {
return team.code.trim()
}
/** 权重的文本形式:1(缺省 / 均分)留空,其余按原值显示,省得均分时每行都写一个 1。 */
function weightTextOf(weight: number): string {
return weight === 1 ? '' : String(weight)
}
/**
* 打开编辑器时的初值:这条报名当前的份额。
*
* 读侧保证「至少一条」,老服务端没有 `teamShares` 时按主单位独占补一条——
* 于是单单位报名打开后就是一行「主单位 · 权重留空」,什么都不改直接关掉等于没动过。
*/
export function shareDraftOf(source: ShareSourceLike): ShareDraftRow[] {
return normalizeTeamShares(source.teamShares, {
teamId: source.teamId,
weight: 1,
teamCode: source.teamCode,
teamName: source.teamName,
}).map((share) => ({
teamId: share.teamId,
teamCode: share.teamCode,
teamName: share.teamName,
weightText: weightTextOf(share.weight),
}))
}
/** 权重输入框原文的解析结果:`value` 已按「空 / 0 / 非数 = 1」归一。 */
export interface ShareWeightParse {
value: number
/** 填了负数(服务端也会 400)。 */
negative: boolean
}
/**
* 解析权重输入框:留空、0、非数字一律按 1(均分);负数单独标出来给界面报错。
*
* 与服务端 entryShareWeight / shareWeight 同口径:份额是相对权重,「不填」就是 1。
*/
export function parseShareWeightText(text: string): ShareWeightParse {
const raw = text.trim()
if (raw === '') return { value: 1, negative: false }
const value = Number(raw)
if (!Number.isFinite(value)) return { value: 1, negative: false }
if (value < 0) return { value: 1, negative: true }
return { value: shareWeightOf(value), negative: false }
}
/** 编辑器 → 写侧载荷(teamId + weight,+ 显式关闭的 medal)。 */
export function shareDraftPayload(draft: readonly ShareDraftRow[]): EntryTeamShare[] {
return draft.map((row) => ({ teamId: row.teamId, weight: parseShareWeightText(row.weightText).value }))
}
/** 编辑器 → 展示视图(份额百分比、联队名),与只读渲染共用同一份口径。 */
export function shareDraftView(draft: readonly ShareDraftRow[]): TeamShareView[] {
return normalizeTeamShares(
draft.map((row) => ({
teamId: row.teamId,
weight: parseShareWeightText(row.weightText).value,
teamCode: row.teamCode,
teamName: row.teamName,
})),
)
}
/**
* 编辑器当前的校验问题(能直接上屏的中文)。
*
* 这些规则服务端都会再判一次、并用它自己的话回 400;前端先判一遍只是为了让操作员
* **在点上保存之前**就看到哪里不对,而不是发一个注定失败的请求。
*/
export function shareDraftIssues(
draft: readonly ShareDraftRow[],
primaryTeamId: number,
primaryName: string,
): string[] {
const issues: string[] = []
if (draft.length === 0) {
issues.push('至少要有一个成员单位:联队也是从一条报名开始的')
return issues
}
if (draft.length > MAX_JOINT_TEAM_MEMBERS) {
issues.push(`一个联队最多 ${MAX_JOINT_TEAM_MEMBERS} 个单位,当前有 ${draft.length} 个`)
}
if (!draft.some((row) => row.teamId === primaryTeamId)) {
issues.push(`主单位「${primaryName}」不能去掉:报名挂在它名下,换主单位要重新建报名`)
}
const seen = new Set<number>()
for (const row of draft) {
if (seen.has(row.teamId)) issues.push(`单位「${row.teamName}」出现了两次:同一个单位只能占一份`)
seen.add(row.teamId)
if (parseShareWeightText(row.weightText).negative) {
issues.push(`单位「${row.teamName}」的权重是负数:留空或填 0 都按 1 算(均分)`)
}
}
return issues
}
/** 编辑器里能加进联队的单位:不在成员里的都可以(含已停用单位,服务端只拦假删除的)。 */
export function selectableShareTeams(
teams: readonly Team[],
draft: readonly ShareDraftRow[],
): Team[] {
const picked = new Set(draft.map((row) => row.teamId))
return teams.filter((team) => team.deletedAt == null && !picked.has(team.id))
}
export function addShareDraftTeam(draft: readonly ShareDraftRow[], team: Team): ShareDraftRow[] {
if (draft.some((row) => row.teamId === team.id)) return [...draft]
return [
...draft,
{ teamId: team.id, teamCode: codeOf(team), teamName: team.name, weightText: '' },
]
}
/** 移除一个成员单位;主单位由调用方拦下(界面上它的按钮是禁用的)。 */
export function removeShareDraftTeam(draft: readonly ShareDraftRow[], teamId: number): ShareDraftRow[] {
return draft.filter((row) => row.teamId !== teamId)
}
export function setShareDraftWeight(
draft: readonly ShareDraftRow[],
teamId: number,
weightText: string,
): ShareDraftRow[] {
return draft.map((row) => (row.teamId === teamId ? { ...row, weightText } : row))
}
/** 编辑器有没有改动:单位集合与各自权重一致就算没动,直接关掉等于没发生过。 */
export function shareDraftDirty(
draft: readonly ShareDraftRow[],
original: readonly ShareDraftRow[],
): boolean {
if (draft.length !== original.length) return true
for (let i = 0; i < draft.length; i++) {
const a = draft[i]
const b = original[i]
if (a === undefined || b === undefined) return true
if (a.teamId !== b.teamId) return true
if (parseShareWeightText(a.weightText).value !== parseShareWeightText(b.weightText).value) return true
}
return false
}
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 EntryTeamSharesResult {
ok: boolean
data?: unknown
error?: unknown
}
export interface EntryTeamSharesOutcome {
entry: Entry | null
/** 服务端回显的份额;老服务端 / 响应里没有时为空,调用方退回本地草稿。 */
teamShares: EntryTeamShare[] | null
}
/**
* 提交份额(整组替换)。写操作不降级到示例数据:示例数据模式下的「保存成功」
* 会让操作员以为分已经改好了,页面必须先看 data.isMock 再决定是否调用这里。
*
* 校验失败(400)时服务端回的是**给人看的中文**(单位重复 / 权重为负 / 缺主单位…),
* 这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
*/
export async function submitEntryTeamShares(
entryId: number,
teamShares: EntryTeamShare[],
requestId?: string,
): Promise<EntryTeamSharesOutcome> {
// 一次调用只生成一个幂等键: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(entryTeamSharesPath(entryId), {
method: 'PUT',
headers,
credentials: 'include',
body: JSON.stringify({ teamShares, 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 EntryTeamSharesResult
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
const shares = data !== null && Array.isArray(data.teamShares) ? data.teamShares : null
return { entry: data, teamShares: shares }
}
+279
View File
@@ -0,0 +1,279 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError, MAX_JOINT_TEAM_MEMBERS } from '@/shared'
import type { EntryTeamShare, Team } from '@/shared'
import {
addShareDraftTeam,
entryTeamSharesPath,
parseShareWeightText,
removeShareDraftTeam,
selectableShareTeams,
setShareDraftWeight,
shareDraftDirty,
shareDraftIssues,
shareDraftOf,
shareDraftPayload,
shareDraftView,
submitEntryTeamShares,
} from '../pages/entryShares'
import type { ShareDraftRow } from '../pages/entryShares'
/**
* 「成员单位与份额」(联队)的编辑模型与写请求契约(devdocs/19)。要钉住的是:
* 1. **默认一个单位**:单单位报名打开编辑器就是孤零零一行主单位、权重留空,什么都不改
* 等于没动过(保存按钮不会亮,请求也不会发)——常规报名流程一点没被碰到;
* 2. 留空 / 0 的权重按 1 算(均分),2:1 显示成 67% 与 33%;
* 3. 请求走 PUT /api/entries/{id}/team-shares,body 只带 teamId / weight,
* 写请求必须有 CSRF 与同一个幂等键(header 与 body 一致);
* 4. 服务端的中文 400 原样抛出,界面直接展示那句话。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: 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 team(id: number, over: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `第${id}分公司`,
shortName: null,
logoPath: null,
color: null,
sortOrder: id,
active: true,
deletedAt: null,
...over,
}
}
function entryOf(shares: EntryTeamShare[] | undefined) {
return { teamId: 1, teamCode: 'A', teamName: '第一分公司', teamShares: shares }
}
test('普通报名(单单位)打开编辑器:只有主单位一行、权重留空,且判定为「没改动」', () => {
const draft = shareDraftOf(entryOf(undefined))
assert.equal(draft.length, 1)
assert.deepEqual(draft[0], { teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' })
assert.equal(shareDraftDirty(draft, shareDraftOf(entryOf(undefined))), false)
assert.deepEqual(shareDraftIssues(draft, 1, '第一分公司'), [])
})
test('份额缺省权重 1,编辑器里留空(不写一个没有意义的 1)', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 2, teamCode: 'B', teamName: '第二分公司' },
]),
)
assert.deepEqual(
draft.map((row) => row.weightText),
['', '2'],
)
})
test('留空 / 0 的权重按 1 算(均分);负数单独报错', () => {
assert.deepEqual(parseShareWeightText(''), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText(' '), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('0'), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('abc'), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('1.5'), { value: 1.5, negative: false })
assert.deepEqual(parseShareWeightText('-2'), { value: 1, negative: true })
})
test('2:1 显示成 67% 与 33%,名字写全', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
]),
)
const view = shareDraftView(draft)
assert.deepEqual(
view.map((share) => `${share.teamName} ${share.percent}%`),
['第一分公司 67%', '第二分公司 33%'],
)
})
test('加单位 / 改权重 / 去单位:改过就是脏,改回去又不脏', () => {
const original = shareDraftOf(entryOf(undefined))
let draft = addShareDraftTeam(original, team(2, { code: 'B', name: '第二分公司' }))
assert.equal(draft.length, 2)
assert.equal(shareDraftDirty(draft, original), true)
draft = setShareDraftWeight(draft, 1, '2')
assert.equal(shareDraftDirty(draft, original), true)
// 权重改回缺省、单位去掉 → 与初值完全一致,不该再算改动。
draft = setShareDraftWeight(draft, 1, '')
draft = removeShareDraftTeam(draft, 2)
assert.equal(shareDraftDirty(draft, original), false)
})
test('同一个单位加不进来两次;已删(假删除)的单位不进候选', () => {
const draft = shareDraftOf(entryOf(undefined))
const again = addShareDraftTeam(draft, team(1))
assert.equal(again.length, 1)
const candidates = selectableShareTeams(
[team(1), team(2), team(3, { deletedAt: '2026-09-01T00:00:00Z' })],
draft,
)
assert.deepEqual(
candidates.map((item) => item.id),
[2],
)
})
test('校验:缺主单位 / 单位重复 / 权重为负 / 超过成员上限都要说出来', () => {
const missingPrimary: ShareDraftRow[] = []
const issues = shareDraftIssues(missingPrimary, 1, '第一分公司')
assert.equal(issues.length, 1)
assert.match(issues[0] ?? '', /至少要有一个成员单位/)
const withoutPrimary = shareDraftOf(
entryOf([
{ teamId: 2, weight: 1, teamName: '第二分公司' },
{ teamId: 3, weight: 1, teamName: '第三分公司' },
]),
)
assert.match(shareDraftIssues(withoutPrimary, 1, '第一分公司').join(';'), /主单位「第一分公司」不能去掉/)
const duplicated = [
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
]
assert.match(shareDraftIssues(duplicated, 1, '第一分公司').join(';'), /出现了两次/)
const negative = [
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
{ teamId: 2, teamCode: 'B', teamName: '第二分公司', weightText: '-1' },
]
assert.match(shareDraftIssues(negative, 1, '第一分公司').join(';'), /权重是负数/)
const tooMany = Array.from({ length: MAX_JOINT_TEAM_MEMBERS + 1 }, (_, index) => ({
teamId: index + 1,
teamCode: `T${index + 1}`,
teamName: `第${index + 1}分公司`,
weightText: '',
}))
assert.match(shareDraftIssues(tooMany, 1, '第一分公司').join(';'), /最多 5 个单位/)
})
test('写侧载荷只带 teamId / weight(读侧的 teamCode / teamName 不上行)', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
]),
)
assert.deepEqual(shareDraftPayload(draft), [
{ teamId: 1, weight: 1 },
{ teamId: 2, weight: 1 },
])
})
test('保存走 PUT /api/entries/{id}/team-shares,带 CSRF 与同一个幂等键', async () => {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
try {
respondWith(() =>
jsonResponse({
ok: true,
data: {
id: 7,
teamId: 1,
teamShares: [
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
],
},
}),
)
const outcome = await submitEntryTeamShares(7, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1 },
])
assert.equal(lastRequest.url, entryTeamSharesPath(7))
assert.equal(lastRequest.url, '/api/entries/7/team-shares')
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')
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, headerId)
assert.deepEqual((lastRequest.body as { teamShares: unknown }).teamShares, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1 },
])
// 服务端回显的份额交给页面直接回填。
assert.equal(outcome.teamShares?.length, 2)
assert.equal(outcome.entry?.id, 7)
} finally {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
}
})
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
respondWith(() =>
jsonResponse(
{ ok: false, error: { code: 'VALIDATION_FAILED', message: 'teamShares 必须包含这条报名的主单位 1:主单位不能从份额里去掉(换主单位要重新建报名)' } },
400,
),
)
await assert.rejects(
() => submitEntryTeamShares(7, [{ teamId: 2, weight: 1 }]),
(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(
() => submitEntryTeamShares(7, [{ teamId: 1, weight: 1 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有改成功/)
return true
},
)
})
+181
View File
@@ -0,0 +1,181 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
/**
* 联队份额在录入页实时预览里的口径(devdocs/19,逐条对齐服务端 domain/scoring.go)。
*
* 录入页的「实时积分预览」是照着引擎写的第二份实现,它必须与服务端同口径:
* 联队那条报名拿到的 15 分要拆成 A、B 各 7.5,而不是整份记在主单位头上——
* 否则操作员看到的是「预览说 A +15」,发布后却是「A、B 各 +7.5」。
* 同时钉住:**没有份额(普通报名)时逐位不变**。
*/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'BBALL',
name: '篮球',
eventType: 'team',
resultMode: 'auto',
resultType: 'score',
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
}
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return {
id: 1,
eventId: 1,
scope: 'team',
basis: 'result',
profileId: 1,
sortOrder: 1,
countToTeam: true,
...overrides,
}
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
/** 团体第 1 名 15 分、第 2 名 9 分。 */
const PROFILE = makeProfile({
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 9 },
],
})
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('普通报名(没有 shares)时逐位不变:分整份记在主单位头上', () => {
const result = computeEventScore(
[{ entryId: 1, teamId: 100, athleteId: null, resultValue: 90, rank: null, status: 'ok', isTie: false }],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
close(result.rowPoints.get(1) ?? 0, 15)
close(result.teamPoints.get(100) ?? 0, 15)
assert.deepEqual(result.rowTeamShares.get(1), [{ teamId: 100, points: 15 }])
})
test('联队 1:1:15 分拆成两个单位各 7.5,总分不变', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: null,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
// 表格里的「积分」列是这条报名拿到的分(还没拆),仍是 15。
close(result.rowPoints.get(1) ?? 0, 15)
close(result.teamPoints.get(100) ?? 0, 7.5)
close(result.teamPoints.get(200) ?? 0, 7.5)
assert.deepEqual(result.rowTeamShares.get(1), [
{ teamId: 100, points: 7.5 },
{ teamId: 200, points: 7.5 },
])
})
test('联队 2:1:拆成 10 与 5,两份之和正好等于原来的那一份分', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: null,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 2 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
close(result.teamPoints.get(100) ?? 0, 10)
close(result.teamPoints.get(200) ?? 0, 5)
close((result.teamPoints.get(100) ?? 0) + (result.teamPoints.get(200) ?? 0), 15)
})
test('个人通道的联队:分先按个人→单位系数折算,再按份额拆', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: 5,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel({ scope: 'athlete' })], profiles: [PROFILE], weight: 0.5 }),
)
// 15 分 × 0.5(个人→单位系数)= 7.5,再两家对半:各 3.75。
close(result.rowTeamPoints.get(1) ?? 0, 7.5)
close(result.teamPoints.get(100) ?? 0, 3.75)
close(result.teamPoints.get(200) ?? 0, 3.75)
})
test('聚合通道不拆份额:名次主体是单位,与服务端口径一致', () => {
const rows: ScorableRow[] = [
{
entryId: 1,
teamId: 100,
athleteId: 5,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
{ entryId: 2, teamId: 200, athleteId: 6, resultValue: 80, rank: null, status: 'ok', isTie: false },
]
const result = computeEventScore(rows, makeContext({ channels: [makeChannel({ basis: 'aggregate' })], profiles: [PROFILE] }))
// 名次按单位平均分排:100 → 90,200 → 80,所以 100 拿 15、200 拿 9,都整份记给主体单位。
close(result.teamPoints.get(100) ?? 0, 15)
close(result.teamPoints.get(200) ?? 0, 9)
})
+14 -2
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { medalBoardRows } from '@/shared'
import type { EventResultRow } from '@/shared'
import { tickerRowText } from '../jointTeams'
import { useScoreboard } from '../useScoreboard'
import { DEFAULT_ROTATE_SECONDS } from '../guards'
@@ -11,7 +13,17 @@ interface TickerItem {
label: string
}
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
const { standings, latestResults, announceText, detail, settings, jointTeamOf } = useScoreboard()
/**
* 底栏「最新完成项目」一行里的名字:团体行是联队时写「A+B联队」。
*
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
* 这条成绩是它一家拿的(成员单位来自报名表,见 display/jointTeams.ts)。
*/
function chipName(row: EventResultRow): string {
return tickerRowText(row, jointTeamOf(row.entryId))
}
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
const topMedals = computed(() => medalBoardRows(standings.value).slice(0, 3))
@@ -77,7 +89,7 @@ onUnmounted(() => window.clearInterval(timer))
</div>
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
<span class="chip-rank">{{ row.rank ?? '—' }}</span>
<span class="chip-name">{{ row.athleteName ?? row.teamName }}</span>
<span class="chip-name">{{ chipName(row) }}</span>
<span class="chip-stat">{{ row.resultText }}</span>
</div>
</template>
@@ -1,14 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { EventResultRow } from '@/shared'
import { rowTeamLogos, rowTeamText } from '../../jointTeams'
import { displayName, displayResult } from '../../results'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { latestResults, options } = useScoreboard()
const { latestResults, options, jointTeamOf } = useScoreboard()
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
/**
* 单位列的文案与 logo(devdocs/19):联队写「A+B联队」并画两个单位的 logo,
* 普通报名与改造前逐字/逐图一致(`shortName ?? teamName` 与单个 TeamLogo)。
*/
function teamText(row: EventResultRow): string {
return rowTeamText(row, jointTeamOf(row.entryId))
}
function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId))
}
</script>
<template>
@@ -28,9 +42,10 @@ const groups = computed(() => latestResults.value.slice(0, options.value.results
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 -->
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
<span class="team-name">{{ teamText(row) }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
+36 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import type { EventResultRow } from '@/shared'
import { fitLiveRowFontPx, liveResultColumnPx, liveTeamColumnPx } from '../../fitFont'
import {
LIVE_REFRESH_INTERVAL_MS,
@@ -10,12 +11,26 @@ import {
pickLiveColumns,
} from '../../live'
import type { LiveEventSource } from '../../live'
import { rowLogoCount, rowTeamLogos, rowTeamText } from '../../jointTeams'
import { displayName, displayResult } from '../../results'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents, jointTeamOf } =
useScoreboard()
/**
* 联队(devdocs/19):成绩行里只有主单位,成员单位来自报名表(useScoreboard 会为屏上的小项读一次)。
* 普通报名下这两件事与改造前完全一样——文案还是 `shortName ?? teamName`,logo 还是刚一张。
*/
function teamText(row: EventResultRow): string {
return rowTeamText(row, jointTeamOf(row.entryId))
}
function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId))
}
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
@@ -67,13 +82,26 @@ const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.valu
const resultTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
)
// 联队行的单位名更长(两个单位名都写全),列宽预算必须按**实际会写出来的字**算,
// 否则联队一到屏上就被挤成省略号。
const teamTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => row.shortName ?? row.teamName)),
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
)
// 有单位 logo 时单位名那一格还要多占一个 logo 宽(TeamLogo 是 1em + 0.28em 间距)。
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
const teamsWithLogo = computed(() =>
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
)
const maxLogosPerRow = computed(() =>
boards.value.reduce(
(max, board) =>
Math.max(
max,
...board.rows.map((row) => rowLogoCount(row, jointTeamOf(row.entryId))),
0,
),
0,
),
)
const rowFontPx = computed(() =>
fitLiveRowFontPx(
liveBlockRowCount(rowCount.value, layout.value),
@@ -81,6 +109,7 @@ const rowFontPx = computed(() =>
resultTexts.value,
teamTexts.value,
teamsWithLogo.value,
maxLogosPerRow.value,
),
)
@@ -92,6 +121,7 @@ const boardStyle = computed(() => ({
layout.value,
rowFontPx.value,
teamsWithLogo.value,
maxLogosPerRow.value,
)}px`,
}))
@@ -141,9 +171,10 @@ onUnmounted(() => {
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
<span class="team-name">{{ teamText(row) }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
+26 -7
View File
@@ -193,18 +193,33 @@ function widestResultEm(results: readonly string[]): number {
/** 单位列的字号比例:与 LivePage.vue 里 `.team { font-size: 0.78em }` 一致。 */
const TEAM_FONT_EM = 0.78
/** 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。 */
/**
* 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。
*
* 联队一行要放**两个**单位的 logo(devdocs/19),所以这个宽度由调用方按「最多的那一行有几个 logo」
* 乘出来传进来,写死一个 logo 的宽会让第二个 logo 把单位名挤成省略号。
*/
const TEAM_LOGO_EM = 1.28
/** 单位名列最多留几个 logo 的宽度:再多也只是白占宽度,8 个已经远超现场(联队上限 5)。 */
const MAX_TEAM_LOGOS = 8
/** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */
const NAME_MIN_EM = 4
/** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */
function logoWidthEm(withLogo: boolean, logoCount = 1): number {
if (!withLogo) return 0
const count = Number.isFinite(logoCount) ? Math.floor(logoCount) : 1
return TEAM_LOGO_EM * Math.min(MAX_TEAM_LOGOS, Math.max(1, count))
}
/** 一屏所有单位名里最宽的那个(em),已经折成单位列自己的字号。 */
function widestTeamEm(teams: readonly string[], withLogo: boolean): number {
function widestTeamEm(teams: readonly string[], withLogo: boolean, logoCount = 1): number {
let widest = 0
for (const text of teams) widest = Math.max(widest, textEm(text))
if (widest <= 0) return 0
return (widest + (withLogo ? TEAM_LOGO_EM : 0)) * TEAM_FONT_EM
return (widest + logoWidthEm(withLogo, logoCount)) * TEAM_FONT_EM
}
/**
@@ -220,9 +235,10 @@ export function liveTeamColumnPx(
layout: LiveLayout,
fontPx: number,
withLogo = false,
logoCount = 1,
): number {
const row = LIVE_ROW_GEOMETRY[layout]
const need = Math.ceil(widestTeamEm(teams, withLogo) * fontPx) + RESULT_COLUMN_PAD_PX
const need = Math.ceil(widestTeamEm(teams, withLogo, logoCount) * fontPx) + RESULT_COLUMN_PAD_PX
return Math.min(row.teamBudgetPx, Math.max(row.teamFloorPx, need))
}
@@ -238,11 +254,12 @@ export function liveNameColumnPx(
layout: LiveLayout,
fontPx: number,
withLogo = false,
logoCount = 1,
): number {
const row = LIVE_ROW_GEOMETRY[layout]
const free =
row.rowWidthPx - row.rankPx - row.gapPx * 3 - liveResultColumnPx(results, layout, fontPx)
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo)
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo, logoCount)
if (!row.teamSharesWidth) return free - team
const share = (free * row.teamFr) / (row.nameFr + row.teamFr)
return free - Math.max(team, share)
@@ -255,8 +272,9 @@ function liveRowFitsPx(
layout: LiveLayout,
fontPx: number,
withLogo: boolean,
logoCount: number,
): boolean {
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo)
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount)
return name >= NAME_MIN_EM * fontPx
}
@@ -279,6 +297,7 @@ export function fitLiveRowFontPx(
results: readonly string[],
teams: readonly string[] = [],
teamsWithLogo = false,
logoCount = 1,
): number {
const spec = LIVE_LAYOUT_SPECS[layout]
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
@@ -287,7 +306,7 @@ export function fitLiveRowFontPx(
if (widest > 0) {
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)) {
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount)) {
font--
}
return Math.max(MIN_ROW_FONT_PX, font)
+109
View File
@@ -0,0 +1,109 @@
import { isJointTeam, jointTeamLabel, normalizeTeamShares } from '@/shared'
import type { EntryTeamShare, Team, TeamShareView } from '@/shared'
/**
* 大屏侧的联队渲染(devdocs/19)。
*
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有**主单位**的 teamId 与名字,
* 一条报名挂在几个单位下这件事只存在于报名表(`GET /api/events/{id}/entries` 的 teamShares)。
* 所以这里做两件事:
* 1. 把那个接口的读数收敛成 `entryId → 成员单位`(名字取自份额,logo 取自快照的单位表——
* entries 接口不下发 logoPath);
* 2. 把「一行该怎么念、放几个 logo」抽成纯函数,页面只负责把结果画出来。
*
* 单单位(普通报名)时这些函数一律退回调用方原来的表达式,屏上逐像素不变。
*/
/** 大屏侧的一条份额:展示字段之外补上 logo(从快照单位表里取)。 */
export interface DisplayTeamShare extends TeamShareView {
logoPath: string | null
}
/** 解析 entries 接口里一项报名的 teamShares;认不出来时返回空数组(= 没有配置)。 */
export function readEntryTeamShares(source: unknown): EntryTeamShare[] {
if (typeof source !== 'object' || source === null) return []
const raw = (source as { teamShares?: unknown }).teamShares
if (!Array.isArray(raw)) return []
return raw.filter((item): item is EntryTeamShare => typeof item === 'object' && item !== null)
}
/**
* 一批报名 → `entryId → 成员单位`,**只收联队**(成员 ≥ 2)。
*
* 单单位报名不进表:调用方拿到的 null 就是「按原来那样渲染」,于是普通报名的成绩行
* 与大屏改造前完全一致(连一个多余的节点都不多)。
* logo 从快照单位表里按 teamId 取,缺单位表(老快照 / 缓存)时是 null,页面自然不画第二个 logo。
*/
export function entryShareIndex(
entries: readonly unknown[],
teams: readonly Team[] = [],
): Map<number, DisplayTeamShare[]> {
const byTeam = new Map<number, Team>(teams.map((team) => [team.id, team]))
const out = new Map<number, DisplayTeamShare[]>()
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) continue
const rec = entry as { id?: unknown; teamId?: unknown; teamCode?: unknown; teamName?: unknown }
const entryId = typeof rec.id === 'number' ? rec.id : 0
const primaryTeamId = typeof rec.teamId === 'number' ? rec.teamId : 0
if (entryId <= 0) continue
const members = normalizeTeamShares(readEntryTeamShares(entry), {
teamId: primaryTeamId,
weight: 1,
teamCode: typeof rec.teamCode === 'string' ? rec.teamCode : '',
teamName: typeof rec.teamName === 'string' ? rec.teamName : '',
}).map((share) => ({
...share,
logoPath: byTeam.get(share.teamId)?.logoPath ?? null,
}))
if (!isJointTeam(members)) continue
out.set(entryId, members)
}
return out
}
/** 一行成绩(分项成绩页 / 实时赛况页)在单位列里的文案。 */
export function rowTeamText(
row: { shortName: string | null; teamName: string },
members: readonly TeamShareView[] | null,
): string {
if (members === null) return row.shortName ?? row.teamName
return jointTeamLabel(members)
}
/**
* 底栏「最新完成项目」一行里的名字:有运动员就写运动员,团体行才写单位。
*
* 团体行是联队时写「A+B联队」——底栏只有一行字,写不下两个 logo,
* 但两个单位的名字必须都在,否则现场会以为只有一个单位参赛。
*/
export function tickerRowText(
row: { athleteName: string | null; shortName: string | null; teamName: string },
members: readonly TeamShareView[] | null,
): string {
if (row.athleteName !== null && row.athleteName.trim() !== '') return row.athleteName
return rowTeamText(row, members)
}
/**
* 单位列要画的 logo:联队时是**行自己的 logo + 其余成员的 logo**,普通报名只有一个元素
* (可能与改造前一样是 null,TeamLogo 不渲染任何东西)。
*
* 行自己的 logoPath 就是主单位的 logo(服务端的读侧口径),所以成员里跳过主单位那一条,
* 免得同一个 logo 画两遍。
*/
export function rowTeamLogos(
row: { teamId: number; logoPath: string | null },
members: readonly DisplayTeamShare[] | null,
): (string | null)[] {
if (members === null) return [row.logoPath]
const extra = members.filter((member) => member.teamId !== row.teamId)
return [row.logoPath, ...extra.map((member) => member.logoPath)]
}
/** 一屏里一行最多要画几个 logo:字号与单位列宽要按它留位置(见 fitFont 的 logoEm)。 */
export function rowLogoCount(
row: { teamId: number; logoPath: string | null },
members: readonly DisplayTeamShare[] | null,
): number {
return rowTeamLogos(row, members).filter((path) => path !== null && path !== '').length
}
+195
View File
@@ -0,0 +1,195 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { Team } from '@/shared'
import {
entryShareIndex,
readEntryTeamShares,
rowLogoCount,
rowTeamLogos,
rowTeamText,
tickerRowText,
} from '../jointTeams'
/**
* 大屏的联队渲染(devdocs/19)。要钉住的是:
* 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张,
* 联队成员表里根本没有这条报名的位置;
* 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画;
* 3. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath),
* 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。
*/
function team(id: number, over: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `第${id}分公司`,
shortName: null,
logoPath: null,
color: null,
sortOrder: id,
active: true,
deletedAt: null,
...over,
}
}
const TEAMS: Team[] = [
team(1, { code: 'A', name: '第一分公司', shortName: '一司', logoPath: '/uploads/a.png' }),
team(2, { code: 'B', name: '第二分公司', shortName: '二司', logoPath: '/uploads/b.png' }),
team(3, { code: 'C', name: '第三分公司', shortName: '三司', logoPath: null }),
]
test('teamShares 解析:缺字段 / 类型不对一律当「没有配置」,不抛异常', () => {
assert.deepEqual(readEntryTeamShares({}), [])
assert.deepEqual(readEntryTeamShares({ teamShares: null }), [])
assert.deepEqual(readEntryTeamShares({ teamShares: 'A+B' }), [])
assert.deepEqual(readEntryTeamShares(null), [])
assert.equal(readEntryTeamShares({ teamShares: [{ teamId: 1, weight: 1 }] }).length, 1)
})
test('联队进表:成员单位带上各自的 logo(名字与 logo 都来自两处不同的接口)', () => {
const index = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamCode: 'A',
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
],
},
],
TEAMS,
)
const members = index.get(11)
assert.equal(members?.length, 2)
assert.equal(members?.[1]?.logoPath, '/uploads/b.png')
assert.equal(members?.[1]?.percent, 50)
})
test('普通报名(单单位 / 老服务端没有 teamShares)不进表 = 屏上按原来那样渲染', () => {
const index = entryShareIndex(
[
{ id: 11, teamId: 1, teamCode: 'A', teamName: '第一分公司' },
{
id: 12,
teamId: 1,
teamCode: 'A',
teamName: '第一分公司',
teamShares: [{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' }],
},
],
TEAMS,
)
assert.equal(index.size, 0)
assert.equal(index.get(11), undefined)
})
test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」', () => {
const row = { shortName: '一司', teamName: '第一分公司' }
assert.equal(rowTeamText(row, null), '一司')
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司')
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
// 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。
assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队')
})
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」', () => {
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
assert.equal(
tickerRowText({ athleteName: '张三', shortName: '一司', teamName: '第一分公司' }, members),
'张三',
)
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, members),
'第一分公司+第二分公司联队',
)
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null),
'一司',
)
})
test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => {
const row = { teamId: 1, logoPath: '/uploads/a.png' }
assert.deepEqual(rowTeamLogos(row, null), ['/uploads/a.png'])
assert.equal(rowLogoCount(row, null), 1)
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
// 主单位的 logo 来自行本身(服务端读侧口径),成员单位的 logo 来自快照单位表,不重复画。
assert.deepEqual(rowTeamLogos(row, members), ['/uploads/a.png', '/uploads/b.png'])
assert.equal(rowLogoCount(row, members), 2)
// 成员没有 logo 时是 null(TeamLogo 不渲染),不影响另一个 logo 与名字。
const noLogo = entryShareIndex(
[
{
id: 12,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 3, weight: 1, teamName: '第三分公司' },
],
},
],
TEAMS,
).get(12)
assert.ok(noLogo !== null && noLogo !== undefined)
assert.deepEqual(rowTeamLogos(row, noLogo), ['/uploads/a.png', null])
assert.equal(rowLogoCount(row, noLogo), 1, '没有 logo 的那一份不占 logo 宽度')
})
test('大屏为屏上的小项读报名表:读失败静默(屏上仍是主单位,不清屏)', () => {
const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表')
assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补')
assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏')
assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位')
})
+8 -4
View File
@@ -289,7 +289,9 @@ test('页面按栏数切 class,三种文案都不留白', () => {
assert.match(source, /正在获取当前项目…/)
assert.match(source, /暂无进行中的项目/)
assert.match(source, /该项目暂无已发布的成绩/)
assert.match(source, /:path="row\.logoPath"/)
// 单位 logo 按 teamLogos(row) 逐张画:联队一行两个单位的 logo 都要出现(普通报名只有一个元素)。
assert.match(source, /v-for="\(logo, index\) in teamLogos\(row\)"/)
assert.match(source, /:path="logo"/)
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
@@ -330,13 +332,15 @@ test('单位列宽同样由内容算出来,长单位名不再被写死列宽
// 单栏与拆两块:单位列写成 minmax(内容宽, 1fr),短名字时与运动员列一样宽,观感不变。
assert.match(source, /minmax\(var\(--team-width, 300px\), 1fr\)/)
assert.match(source, /minmax\(var\(--team-width, 150px\), 1fr\)/)
// 页面必须把单位名与 logo 一起喂给字号计算,否则又变成「算完字号再被单位名挤破」。
// 页面必须把单位名(联队时是「A+B联队」)与 logo 一起喂给字号计算,
// 否则又变成「算完字号再被单位名挤破」;logo 的份数也要喂(联队一行两个 logo)。
assert.match(source, /liveTeamColumnPx/)
assert.match(source, /row\.shortName \?\? row\.teamName/)
assert.match(source, /rowTeamText\(row, jointTeamOf\(row\.entryId\)\)/)
assert.match(source, /rowLogoCount\(row, jointTeamOf\(row\.entryId\)\)/)
assert.match(source, /'--team-width': `\$\{liveTeamColumnPx/)
assert.match(
source,
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,/,
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,\s*maxLogosPerRow\.value,/,
)
})
+43 -10
View File
@@ -8,19 +8,26 @@ import { compileTemplate, parse } from 'vue/compiler-sfc'
const LOGO_SOURCE = readFileSync(new URL('../components/TeamLogo.vue', import.meta.url), 'utf8')
/** 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)。 */
const PAGES: readonly [string, string][] = [
['StandingsPage.vue', 'name'],
['TeamMedalsPage.vue', 'name'],
['EventResultsPage.vue', 'team'],
['AthleteStandingsPage.vue', 'name'],
/**
* 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)+ 该页 `:path` 的表达式。
*
* 三张榜单页的行本来就属于某个单位,logo 直接取 `row.logoPath`;
* 分项成绩页在联队时要画**两个**单位的 logo(devdocs/19),所以它按 `teamLogos(row)` 逐张渲染
* ——普通报名那个数组只有一个元素 `row.logoPath`,屏上与改造前一模一样。
*/
const PAGES: readonly [string, string, string][] = [
['StandingsPage.vue', 'name', 'row.logoPath'],
['TeamMedalsPage.vue', 'name', 'row.logoPath'],
['EventResultsPage.vue', 'team', 'logo'],
['AthleteStandingsPage.vue', 'name', 'row.logoPath'],
]
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
const NODE_ELEMENT = 1
const NODE_IF = 9
const NODE_ATTRIBUTE = 6
const NODE_DIRECTIVE = 7
const NODE_IF = 9
const NODE_FOR = 11
interface AstAttr {
type: number
@@ -55,12 +62,20 @@ function classTokens(node: AstNode): string[] {
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
}
/** 直接子元素;v-if 分支展开一层,免得元素被条件包住时漏看。 */
/**
* 直接子元素;v-if 分支与 v-for 循环展开一层,免得元素被条件 / 循环包住时漏看。
*
* v-for 展开是联队要的:分项成绩页的单位列按 `teamLogos(row)` 逐张画 logo(普通报名时数组
* 只有一个元素),渲染时它就是一个循环,不展开的话这一列看着一个 logo 都没有。
* 展开后「子元素个数」数的仍是模板里写了几处,不受运行时数组长度影响。
*/
function childElements(node: AstNode): AstNode[] {
const out: AstNode[] = []
for (const child of node.children ?? []) {
if (child.type === NODE_IF) {
for (const branch of child.branches ?? []) out.push(...branch.children)
} else if (child.type === NODE_FOR) {
out.push(...childElements(child))
} else {
out.push(child)
}
@@ -153,7 +168,7 @@ test('TeamLogo 用 em 定尺寸、object-fit: contain,不撑行高', () => {
assert.match(rule, /flex:\s*none/, 'logo 不得被 flex 压缩变形')
})
for (const [page, column] of PAGES) {
for (const [page, column, pathExpr] of PAGES) {
test(`${page} 用 TeamLogo 在 ${column} 列的名字前渲染单位 logo`, () => {
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
const ast = templateAst(source, page)
@@ -166,7 +181,7 @@ for (const [page, column] of PAGES) {
container.classes.includes(column),
`logo 应放在 .${column} 列里,实际是 .${container.classes.join('.')}`,
)
assert.equal(container.logoPathExpr, 'row.logoPath', 'logo 应取行数据的 logoPath')
assert.equal(container.logoPathExpr, pathExpr, 'logo 应取行数据的 logoPath')
// 用户要求:logo 在名字前面。名字是容器里紧跟 logo 的那个元素。
assert.equal(container.children[0]?.tag, 'TeamLogo', 'logo 应是这一列的首个子元素')
@@ -175,6 +190,24 @@ for (const [page, column] of PAGES) {
})
}
// 联队(一条报名挂两个单位)时两个单位的 logo 都要出现:分项成绩页的那一列按 teamLogos(row)
// 逐张画(普通报名时数组只有一个元素),路径表达式从 teamLogos 求和,成员 logo 落在数组里。
test('分项成绩页的 logo 列表来自 teamLogos(row),联队时两个都画', () => {
const source = readFileSync(new URL('../components/pages/EventResultsPage.vue', import.meta.url), 'utf8')
const ast = templateAst(source, 'EventResultsPage.vue')
const nodes: AstNode[] = []
collect(ast, nodes)
const logo = nodes.find((node) => node.tag === 'TeamLogo')
assert.ok(logo !== undefined)
assert.equal(boundExpression(logo, 'path'), 'logo', ':path 应为 v-for 的每一项')
assert.match(
source,
/v-for="\(logo, index\) in teamLogos\(row\)"/,
'logo 必须按 teamLogos(row) 循环渲染,否则联队画不出第二个',
)
assert.match(source, /rowTeamLogos\(row, jointTeamOf\(row\.entryId\)\)/)
})
test('四个页面都不再手写 logo 的 <img>,逻辑只留一份', () => {
for (const [page] of PAGES) {
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
+91
View File
@@ -8,6 +8,7 @@ import type {
EventResultGroup,
RealtimeStatus,
StandingRow,
Team,
} from '@/shared'
import {
DEFAULT_ROTATE_SECONDS,
@@ -20,6 +21,8 @@ import {
parseTeamBreakdown,
} from './guards'
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
import { entryShareIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live'
@@ -51,6 +54,8 @@ const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
const events = ref<Event[]>([])
// 单位表:联队成员单位的 logo 只能从这里取(entries 接口不下发 logoPath)。
const teams = ref<Team[]>([])
/**
* 「实时赛况」的当前项目结论(GET /api/display/live)。
*
@@ -183,6 +188,7 @@ function applySnapshot(snapshot: DisplaySnapshot, kind: DisplaySource): void {
settings.value = snapshot.settings
latestResults.value = snapshot.latestResults
events.value = snapshot.events
teams.value = snapshot.teams
version.value = snapshot.version
applyStandings(snapshot.standings)
source.value = kind
@@ -251,6 +257,85 @@ function reportLiveFailure(wasStale: boolean, reason: string): void {
console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`)
}
// ---------------------------------------------------------------------------
// 联队成员(一条报名挂在多个单位下,devdocs/19)
// ---------------------------------------------------------------------------
/**
* `entryId → 联队成员`。只有联队(成员 ≥ 2)在里面,单单位报名取不到 = 按原来那样渲染。
*
* 成绩行里只有主单位,成员单位只存在于报名表,所以大屏要为「正在显示的小项」多读一次
* `GET /api/events/{id}/entries`(public,guest 可读)。拿不到就退回单单位渲染:
* 大屏是无人值守的,为了一句名字把整页搞空不值得。
*/
const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
const sharesFetchedAt = new Map<number, number>()
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
const sharesInFlight = new Set<number>()
/** 每个小项上次读回来的联队报名 id:重读时要先删掉它们,改回单单位的行才不会留在屏上。 */
const shareEntryIds = new Map<number, number[]>()
/**
* 报名表的重读间隔(毫秒)。
*
* 份额改动只影响分记在谁头上,推送的是榜单而不是这条报名,所以屏上要自己按期重读一次;
* 60s 让「改完几十秒内上屏」,同时每个小项每分钟最多一次请求(局域网里可以忽略)。
*/
const SHARES_TTL_MS = 60_000
async function loadEventShares(eventId: number): Promise<void> {
if (sharesInFlight.has(eventId)) return
sharesInFlight.add(eventId)
try {
const entries = await api.events.entries(eventId)
const index = entryShareIndex(entries, teams.value)
const next = new Map(entryShares.value)
for (const id of shareEntryIds.get(eventId) ?? []) next.delete(id)
const ids: number[] = []
for (const [entryId, members] of index) {
next.set(entryId, members)
ids.push(entryId)
}
shareEntryIds.set(eventId, ids)
entryShares.value = next
sharesFetchedAt.set(eventId, Date.now())
} catch {
// 静默:没有联队信息时屏上仍是主单位,与升级前一致。
} finally {
sharesInFlight.delete(eventId)
}
}
/** 拉一批小项的报名表:没拉过的、以及过了重读间隔的。 */
async function ensureEntryShares(eventIds: readonly number[]): Promise<void> {
const now = Date.now()
const due = eventIds.filter(
(eventId) => eventId > 0 && now - (sharesFetchedAt.get(eventId) ?? 0) > SHARES_TTL_MS,
)
await Promise.all(due.map((eventId) => loadEventShares(eventId)))
}
/** 屏上正在显示的小项 id(分项成绩 + 实时赛况去重)。 */
const sharesEventIds = computed(() => {
const ids = new Set<number>()
for (const group of latestResults.value) ids.add(group.eventId)
for (const item of liveFeed.value?.events ?? []) ids.add(item.eventId)
return [...ids].filter((id) => id > 0).sort((a, b) => a - b)
})
/** 只有这串 id 真的变了才去读报名表(展示层的其它变化与此无关)。 */
const sharesKey = computed(() => sharesEventIds.value.join(','))
/** 某个报名项的联队成员;单单位 / 没读到都是 null,调用方退回原来的渲染。 */
function jointTeamOf(entryId: number): DisplayTeamShare[] | null {
return entryShares.value.get(entryId) ?? null
}
watch(sharesKey, () => {
void ensureEntryShares(sharesEventIds.value)
})
/**
* 拉一次「实时赛况」的当前项目(GET /api/display/live)。
*
@@ -293,6 +378,9 @@ async function refreshLiveEvents(): Promise<void> {
} else {
liveFeed.value = feed
liveStale.value = false
// 30s 一次的兜底轮询顺带重读报名表(受 SHARES_TTL_MS 限制,实际每分钟最多一次):
// 现场改完成员单位后,屏上不必等到项目切换才跟上。
void ensureEntryShares(feed.events.map((item) => item.eventId))
}
} catch (error) {
liveStale.value = true
@@ -352,6 +440,7 @@ function activateMock(): void {
settings.value = parseSettings(snapshot.settings)
latestResults.value = snapshot.latestResults
events.value = snapshot.events
teams.value = snapshot.teams
athletes.value = createMockAthletes()
standings.value = snapshot.standings
source.value = 'mock'
@@ -642,6 +731,8 @@ export function useScoreboard() {
athletes,
latestResults,
events,
teams,
jointTeamOf,
liveFeed,
liveStale,
page,
+23
View File
@@ -14,9 +14,11 @@ import type {
BackupRestoreResult,
CurrentUser,
DisplayCommand,
Entry,
EntryHeatPayload,
EntryHeatResult,
EntryImportResult,
EntryTeamShare,
EntryWithResult,
Event,
EventCategory,
@@ -312,6 +314,20 @@ export interface EntriesApi {
* 只改录入分组,不影响任何成绩与积分。
*/
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
/**
* 改写一条报名的成员单位与份额(`PUT /api/entries/{id}/team-shares`,见 devdocs/19)。
*
* 整组替换、幂等(body.requestId 与 X-Request-Id 是同一个键):改完服务端在同一事务里
* 重算该 (小项, 组别) 的流水与名次并推榜,所以成功后的榜单已经是新口径。
* 校验失败回 400 + 人话 message(不能为空 / 必须含主单位 / 单位重复 / 单位不存在 / 权重为负 / 成员超限),
* 调用方原样展示那句话即可——前端不自己改写服务端的措辞。
*/
saveTeamShares(
entryId: number,
teamShares: EntryTeamShare[],
requestId?: string,
): Promise<Entry>
/**
* 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。
*
@@ -929,6 +945,13 @@ export class ApiClient {
},
setHeat: (payload) =>
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
// 不在前端拼 requestId:send() 会生成一个并同时用于 header 与 body,
// 调用方只在重试同一次写操作时才显式传(与批量操作 / 存草稿同一口径)。
saveTeamShares: (entryId, teamShares, requestId) =>
http.request<Entry>(`/entries/${entryId}/team-shares`, {
method: 'PUT',
body: requestId === undefined ? { teamShares } : { teamShares, requestId },
}),
downloadTemplate: (language) =>
http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }),
}
+11
View File
@@ -27,6 +27,17 @@ export {
normalizeCountToTeamMedals,
} from './channels'
export { readTeamRanksView, readTeamRanksViews } from './teamRanks'
export {
MAX_JOINT_TEAM_MEMBERS,
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
shareText,
shareWeightOf,
splitSharePoints,
teamSharePayload,
} from './jointTeams'
export type { TeamShareView } from './jointTeams'
export {
breakdownAthleteName,
breakdownCategoryName,
+153
View File
@@ -0,0 +1,153 @@
import type { EntryTeamShare } from './types'
/**
* 联队(一条报名挂多个单位)的展示口径,前端只有这一份实现(devdocs/19)。
*
* 管理端的录入表 / 复核页与大屏的榜单行 / 底栏都要把同一个东西念成同一个样子,
* 两处各拼一套「A+B联队」,迟早会变成大屏写简称、管理端写全称这种对不上的局面。
*
* 这里只做**展示**:权重的归一(`weight / Σweight`)与名字的拼接。
* 真份额在服务端算(domain.ResolveShares / splitSharePoints),前端不参与分账。
*/
/**
* 一个联队最多几个成员单位。与服务端 `maxJointTeamMembers` 一致。
*
* 上限的作用是拦住「一整格都是加号」这类误填,而不是限制业务(现场最多见过三个单位合并);
* 导入说明与份额编辑器都要说这个数,所以放在共享层,避免两处各写一个 5。
*/
export const MAX_JOINT_TEAM_MEMBERS = 5
/** 展示用的一条份额:权重已归一成整数百分比,名字已补好。 */
export interface TeamShareView {
teamId: number
teamCode: string
teamName: string
/** 相对权重(原值,缺省按 1 处理)。 */
weight: number
/** 展示份额:`weight / Σweight` 的整数百分比(1+1 → 50,2+1 → 67 与 33)。 */
percent: number
/** 这枚奖牌是否整枚记给该单位(缺省 true)。 */
medal: boolean
}
/**
* 权重取值:非有限或 <= 0 一律按 1(均分)。
*
* 与服务端 shareWeight 同口径:份额是相对权重,写 0 就是「不填」,负数在写侧已被 400 拦下,
* 这里是读侧的兜底——老服务端 / 脏数据不该让界面算出 NaN%。
*/
export function shareWeightOf(weight: number | null | undefined): number {
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight <= 0) return 1
return weight
}
function memberName(share: Pick<EntryTeamShare, 'teamId' | 'teamCode' | 'teamName'>): string {
const name = (share.teamName ?? '').trim()
if (name !== '') return name
const code = (share.teamCode ?? '').trim()
return code !== '' ? code : `单位 ${share.teamId}`
}
/**
* 读取侧收口:把接口给的份额补齐成**至少一条**,并算好展示百分比。
*
* `fallback` 是「这条报名的主单位」(外层 `teamId` / `teamCode` / `teamName`),
* 老服务端不下发 `teamShares`、或者份额整组为空时按主单位独占 100% 补一条——
* 这正是升级前所有报名的口径,界面上一个像素都不该变。
*
* 脏数据(teamId 非法、同一单位出现两次)在这里丢掉:界面上把同一个单位列两遍、
* 或者凭空多出一个「单位 0」,比少显示一份更容易误导现场。真份额由服务端校验,这里只是兜底。
*/
export function normalizeTeamShares(
shares: readonly EntryTeamShare[] | null | undefined,
fallback?: EntryTeamShare | null,
): TeamShareView[] {
const list = Array.isArray(shares) ? shares : []
const seen = new Set<number>()
const valid: EntryTeamShare[] = []
for (const share of list) {
if (share === null || typeof share !== 'object') continue
const teamId = share.teamId
if (typeof teamId !== 'number' || !Number.isInteger(teamId) || teamId <= 0) continue
if (seen.has(teamId)) continue
seen.add(teamId)
valid.push(share)
}
const source =
valid.length > 0
? valid
: fallback != null && Number.isInteger(fallback.teamId) && fallback.teamId > 0
? [fallback]
: []
if (source.length === 0) return []
const total = source.reduce((sum, share) => sum + shareWeightOf(share.weight), 0)
return source.map((share) => {
const weight = shareWeightOf(share.weight)
return {
teamId: share.teamId,
teamCode: (share.teamCode ?? '').trim(),
teamName: memberName(share),
weight,
percent: Math.round((weight / total) * 100),
// 缺省(undefined)= 整枚记给该单位;只有显式 false 才是「这枚牌不记给它」。
medal: share.medal !== false,
}
})
}
/** 是不是联队:成员单位多于一个。单单位(普通报名)返回 false,调用方据此走原来的渲染。 */
export function isJointTeam(shares: readonly { teamId: number }[]): boolean {
return shares.length > 1
}
/**
* 联队的展示名:`第一分公司+第二分公司联队`(成员单位名逐个写全,用 + 连起来)。
*
* 用全称而不是简称:现场要认的是「这两个单位合并参赛」这件事,
* 简称(一司、二司)在别的单位的人眼里认不出来。单单位时调用方不该用这个函数。
*/
export function jointTeamLabel(shares: readonly TeamShareView[]): string {
return `${shares.map((share) => share.teamName).join('+')}联队`
}
/** 一条份额的展示文案:`第一分公司 50%`;权重不是 1(非均分)时把权重也写出来。 */
export function shareText(share: TeamShareView): string {
return share.weight === 1 ? `${share.teamName} ${share.percent}%` : `${share.teamName} ${share.percent}%(权重 ${share.weight})`
}
/**
* 把一条成绩的积分按份额拆给各成员单位,返回值与 `shares` 一一对应。
*
* 与服务端 `domain.splitSharePoints` 同口径:先按权重之和归一,最后一份取「总分减去前面各份」
* 的余数——2:1 这种除不尽的权重各份直接算会剩下 1e-15 级零头,榜单上就会印出
* 9.999999999999998 这种数。管理端的实时积分预览靠它把「预览说 A +15」改成「A、B 各 +7.5」。
*/
export function splitSharePoints(total: number, shares: readonly { weight: number }[]): number[] {
const out: number[] = []
if (shares.length === 0) return out
const weights = shares.map((share) => shareWeightOf(share.weight))
const sum = weights.reduce((acc, weight) => acc + weight, 0)
let acc = 0
for (let i = 0; i < shares.length; i++) {
if (i === shares.length - 1) {
out.push(total - acc)
break
}
const part = (total * (weights[i] ?? 1)) / sum
out.push(part)
acc += part
}
return out
}
/** 写侧载荷:只带服务端认的三个字段(teamCode / teamName 是读侧补的展示字段)。 */
export function teamSharePayload(shares: readonly EntryTeamShare[]): EntryTeamShare[] {
return shares.map((share) => {
const item: EntryTeamShare = { teamId: share.teamId, weight: shareWeightOf(share.weight) }
// medal 缺省即「整枚各记」,不下发它就是缺省口径;显式关掉才必须带上。
if (share.medal === false) item.medal = false
return item
})
}
+154
View File
@@ -0,0 +1,154 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
shareText,
shareWeightOf,
splitSharePoints,
teamSharePayload,
} from '../jointTeams'
import type { EntryTeamShare } from '../types'
/**
* 联队的展示与拆分口径(devdocs/19)。要钉住的是:
* 1. **普通报名一字不变**:没有 teamShares(老服务端)/ 只有一条时,收口成主单位独占 100%,
* 拼出来的名字就是主单位名,界面上与升级前完全一样;
* 2. 份额是**相对权重**:1+1 是 50/50,2+1 是 67/33(不是 200/100);
* 3. 积分平均拆、接受 .5:15 分给两家就是 7.5 + 7.5,且 2:1 这种除不尽的权重
* 两份之和必须**逐位等于**总分(末位取余数,不许出现 9.999999999999998)。
*/
function share(over: Partial<EntryTeamShare> & Pick<EntryTeamShare, 'teamId'>): EntryTeamShare {
return { weight: 1, ...over }
}
test('没有 teamShares(老服务端)时按主单位独占 100%', () => {
const shares = normalizeTeamShares(undefined, {
teamId: 3,
weight: 1,
teamCode: 'A',
teamName: '第一分公司',
})
assert.equal(shares.length, 1)
assert.deepEqual(shares[0], {
teamId: 3,
teamCode: 'A',
teamName: '第一分公司',
weight: 1,
percent: 100,
medal: true,
})
assert.equal(isJointTeam(shares), false, '单单位不是联队')
})
test('份额整组为空且没有主单位时返回空数组,不造 0 号单位', () => {
assert.deepEqual(normalizeTeamShares([], null), [])
assert.deepEqual(normalizeTeamShares([], { teamId: 0, weight: 1 }), [])
})
test('1+1 均分:各 50%,联队名把两个单位名都写全', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamCode: 'A', teamName: '第一分公司' }),
share({ teamId: 2, teamCode: 'B', teamName: '第二分公司' }),
])
assert.equal(isJointTeam(shares), true)
assert.deepEqual(
shares.map((item) => item.percent),
[50, 50],
)
assert.equal(jointTeamLabel(shares), '第一分公司+第二分公司联队')
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 50%')
})
test('2+1 是相对权重:67% 与 33%(不是 200% / 100%)', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' }),
share({ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' }),
])
assert.deepEqual(
shares.map((item) => item.percent),
[67, 33],
)
// 非均分时把权重也写出来,现场一眼看得出「为什么不是一半一半」。
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 67%(权重 2)')
})
test('权重缺省 / 0 / 负数一律按 1(均分),不产生 NaN%', () => {
assert.equal(shareWeightOf(undefined), 1)
assert.equal(shareWeightOf(0), 1)
assert.equal(shareWeightOf(-3), 1)
assert.equal(shareWeightOf(Number.NaN), 1)
const shares = normalizeTeamShares([
{ teamId: 1, weight: 0, teamName: '甲' },
{ teamId: 2, weight: Number.NaN, teamName: '乙' },
])
assert.deepEqual(
shares.map((item) => item.percent),
[50, 50],
)
})
test('奖牌缺省 true;只有显式 false 才是「这枚牌不记给它」', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamName: '甲' }),
share({ teamId: 2, teamName: '乙', medal: false }),
])
assert.equal(shares[0]?.medal, true)
assert.equal(shares[1]?.medal, false)
})
test('名字缺失时退回单位编码,再退回「单位 id」,不留空白格', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamCode: 'A' }),
share({ teamId: 2 }),
])
assert.equal(jointTeamLabel(shares), 'A+单位 2联队')
})
test('脏数据(非法 teamId / 重复单位)在读取侧丢掉,界面不列两遍', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamName: '甲' }),
share({ teamId: 1, teamName: '甲的重复行' }),
share({ teamId: 0, teamName: '零号' }),
])
assert.equal(shares.length, 1)
assert.equal(shares[0]?.teamName, '甲')
})
test('积分按份额拆:15 分给两家是 7.5 + 7.5(接受 .5)', () => {
const split = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }])
assert.deepEqual(split, [7.5, 7.5])
})
test('除不尽的权重:各份之和逐位等于总分,不出现浮点零头', () => {
const total = 15
const split = splitSharePoints(total, [{ weight: 2 }, { weight: 1 }])
assert.equal(split[0]! + split[1]!, total, '两份之和必须正好等于总分')
assert.equal(split[1], 15 - split[0]!)
assert.equal(split[0], 10)
assert.equal(split[1], 5)
// 三单位 1:1:1 同样要闭合。
const three = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }, { weight: 1 }])
assert.equal(
three.reduce((sum, part) => sum + part, 0),
15,
)
})
test('单条份额(普通报名)拆出来就是原分,与升级前逐位相同', () => {
assert.deepEqual(splitSharePoints(15, [{ weight: 1 }]), [15])
assert.deepEqual(splitSharePoints(7.5, [{ weight: 2 }]), [7.5])
})
test('写侧载荷只带 teamId / weight(+ 显式关闭的 medal),不带读侧展示字段', () => {
const payload = teamSharePayload([
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, medal: false, teamCode: 'B', teamName: '第二分公司' },
])
assert.deepEqual(payload, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1, medal: false },
])
})
+21
View File
@@ -218,6 +218,22 @@ export type ScoringProfileInput = Omit<ScoringProfile, 'id'>
export type EntryStatus = 'registered' | 'scratched' | 'dq' | 'dns'
/**
* 一条报名的归属份额(联队的一个成员单位)。见 devdocs/19。
*
* `weight` 是**相对权重**(不是百分比):展示份额写成 `weight / Σweight`,缺省 1 = 均分;
* 引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价。`medal` 缺省 true = 这枚奖牌**整枚**记给该单位,
* 只有显式关掉才会是 false(联队的金牌两个单位各记一枚,与权重无关)。
* `teamCode` / `teamName` 是读侧补的展示字段,写侧不必带。
*/
export interface EntryTeamShare {
teamId: number
weight: number
medal?: boolean
teamCode?: string
teamName?: string
}
export interface Entry {
id: number
eventId: number
@@ -238,6 +254,11 @@ export interface Entry {
teamName?: string
shortName?: string | null
categoryName?: string
/**
* 归属份额:读侧**恒为数组且至少一条**,普通报名就是 `[{主单位, weight:1}]`,
* 联队是多条(外层 `teamId` 恒为主单位)。老服务端不下发这个字段,收口在 normalizeTeamShares。
*/
teamShares?: EntryTeamShare[]
}
export interface EntryWithResult extends Entry {