Files
Scoreboard/server/internal/export/entry_members.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

144 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package export
import (
"database/sql"
"fmt"
"strings"
)
// 本文件是「团体报名的队员」(devdocs/20)在导出侧的读法:一条团体报名可以关联多个运动员
// (`entry_athletes`),导出件上要看得出队里坐着谁。
//
// 写法是 `姓名(号码布)`:号码布是运动员的唯一标识(迁移 0014、devdocs/20 §二),
// 光写名字遇到重名就认不出是谁。多个队员用顿号连起来——与 member_names 原有的写法同形,
// 拿导出件对账的人不必为两种名单学两套读法。
//
// member_names **不因为挂了队员而消失**:它是显示用的文字名单,里面没被队员认走的那些名字
// (人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。同一个口径也用在
// 大屏侧(web/src/display/members.ts),两处必须一致,否则同一场比赛的同一支队伍
// 在导出件与屏幕上会写成两份名单。
//
// 队员**不改每一行的高度与条数**:一条报名仍然只占一行(护栏见 entry_members_rows_test.go),
// 队员只让这一格更长。
// entryMember 是报名里一名队员的展示字段。身份是 athlete_id(引擎按它发个人分与个人奖牌),
// 这里只读展示需要的两列:姓名与号码布。
type entryMember struct {
Name string
Bib string
}
// entryMembersReady 判断队员表在不在。
//
// 表不在(库还没跑到 0026)= 库里没有任何队员关联 = 只报单位,与升级前一致,
// 所以按「没有队员」处理是**精确**的,不是容错降级(与 entrySharesReady 同一判据)。
func entryMembersReady(db *sql.DB) (bool, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
return false, fmt.Errorf("export: 查询报名队员表: %w", err)
}
return n != 0, nil
}
// loadEntryMembers 读全库的队员关联,按报名 id 归组。
//
// 顺序按 sort_order(写侧按请求给的顺序编号,老数据按文字名单的书写顺序回填):
// 展示顺序稳定,导出件不会因为主键顺序而变。各导出件都读一次给全部工作表复用——
// 逐张表去查是白跑几十遍(与 loadJointTeams 同一个取舍)。
func loadEntryMembers(db *sql.DB) (map[int64][]entryMember, error) {
ready, err := entryMembersReady(db)
if err != nil {
return nil, err
}
if !ready {
return nil, nil
}
rows, err := db.Query(`
SELECT ea.entry_id, COALESCE(a.name, ''), COALESCE(a.bib, '')
FROM entry_athletes ea
JOIN athletes a ON a.id = ea.athlete_id
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名队员: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]entryMember)
for rows.Next() {
var (
entryID int64
m entryMember
)
if err := rows.Scan(&entryID, &m.Name, &m.Bib); err != nil {
return nil, fmt.Errorf("export: 扫描报名队员: %w", err)
}
out[entryID] = append(out[entryID], m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名队员: %w", err)
}
return out, nil
}
// memberLabel 把一名队员写成导出件上的字:`张伟(A001)`。
//
// 姓名或号码布缺一个时只写有的那个(不留「()」这种空壳);两个都空的人在名单里一个字都不写。
func memberLabel(m entryMember) string {
name := strings.TrimSpace(m.Name)
bib := strings.TrimSpace(m.Bib)
switch {
case name != "" && bib != "":
return name + "(" + bib + ")"
case name != "":
return name
default:
return bib
}
}
// coveredByMember 判断文字名单里的一个名字是不是已经由某名队员写过了。
//
// 判据与迁移 0026 的回填同源:文字名单里写的可能是**姓名**,也可能是**号码布**
// (导入规范允许「队员名单」列填号码布)。认走之后不再重复写一遍,否则一个队员在导出件上
// 会出现两次(「王五(A001)、王五」这种)。
func coveredByMember(name string, members []entryMember) bool {
for _, m := range members {
if name == strings.TrimSpace(m.Name) || name == strings.TrimSpace(m.Bib) {
return true
}
}
return false
}
// participantLabel 是报名名单 / 分项成绩表 / 全部成绩单页 / 单位奖牌明细共用的
// 「姓名」「运动员 / 队」列取值。
//
// 没有队员关联时**逐字**等于 participantName:个人写姓名、团体写文字名单,一个字都不多
// ——老数据(含迁移按文字名单回填不上人的那些)的导出件不许有任何变化。
func participantLabel(athleteName, memberJSON string, members []entryMember) string {
if len(members) == 0 {
return participantName(athleteName, memberJSON)
}
out := make([]string, 0, len(members)+1)
for _, m := range members {
if label := memberLabel(m); label != "" {
out = append(out, label)
}
}
seen := make(map[string]bool)
for _, name := range memberNamesOf(memberJSON) {
name = strings.TrimSpace(name)
if name == "" || seen[name] || coveredByMember(name, members) {
continue
}
seen[name] = true
out = append(out, name)
}
if len(out) == 0 {
// 队员既没姓名也没号码布、文字名单也是空的:退回原来的取值,不把这一格写空。
return participantName(athleteName, memberJSON)
}
return strings.Join(out, "、")
}