## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
144 lines
5.4 KiB
Go
144 lines
5.4 KiB
Go
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, "、")
|
||
}
|