feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。
## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。
## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
`entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。
## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。
## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。
## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。
## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。
## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。
## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
7a3e4ad365
commit
99c91f99f7
57 files changed
+4903
-94
No files matched your search
@@ -431,18 +431,22 @@ 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
|
||||
}
|
||||
members, err := loadEntryMembers(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, joints)
|
||||
rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members)
|
||||
if err != nil {
|
||||
return EventsWorkbook{}, err
|
||||
}
|
||||
@@ -450,7 +454,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, joints)
|
||||
rows, err := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members)
|
||||
if err != nil {
|
||||
return EventsWorkbook{}, err
|
||||
}
|
||||
@@ -529,12 +533,30 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
||||
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
|
||||
//
|
||||
// 队员关联在这里自己读一次。LoadEventWorkbook 逐张小项 / 组别表地调它,所以那边走的是
|
||||
// loadEventRowsWithMembers(读一次给全部表复用),这条只是给单表调用与既有用例留的入口。
|
||||
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) {
|
||||
members, err := loadEntryMembers(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadEventRowsWithMembers(db, eventID, categoryID, includeDrafts, joints, members)
|
||||
}
|
||||
|
||||
// loadEventRowsWithMembers 是 loadEventRows 的本体:队员关联由调用方读好传进来。
|
||||
func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam, members map[int64][]entryMember) ([]EventResultRow, error) {
|
||||
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
|
||||
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
|
||||
// 落不了库)回落到 results.rank,保持名次列不空白。
|
||||
//
|
||||
// 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN:
|
||||
// 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份
|
||||
// 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。
|
||||
// 取最小 id = 报名项那一份(引擎先产出它),名次值与升级前完全一致;
|
||||
// 老数据每 (通道, 组别, 报名项) 最多一行,两种写法逐字节等价。
|
||||
//
|
||||
// gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源:
|
||||
// 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩),
|
||||
// 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。
|
||||
@@ -559,8 +581,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
|
||||
WHERE c2.event_id = en.event_id
|
||||
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
|
||||
LIMIT 1)
|
||||
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
|
||||
AND cr.category_id = en.category_id
|
||||
LEFT JOIN channel_ranks cr ON cr.id = (
|
||||
SELECT MIN(cr2.id) FROM channel_ranks cr2
|
||||
WHERE cr2.channel_id = mc.id AND cr2.entry_id = en.id AND cr2.category_id = en.category_id)
|
||||
LEFT JOIN (
|
||||
SELECT e2.event_id, e2.category_id, COUNT(*) AS required,
|
||||
SUM(CASE WHEN r2.state = 'published'
|
||||
@@ -608,7 +631,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
|
||||
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
|
||||
}
|
||||
row.Participant = participantName(athleteName, memberJSON)
|
||||
row.Participant = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
row.Rank = rankLabel(rank, isTie != 0)
|
||||
row.Status = statusLabel(resultStat, resultState, entryStat)
|
||||
row.Remark = joinRemark(entryRemark, resultNote)
|
||||
@@ -931,13 +954,25 @@ func participantName(athleteName, memberJSON string) string {
|
||||
if strings.TrimSpace(athleteName) != "" {
|
||||
return athleteName
|
||||
}
|
||||
memberJSON = strings.TrimSpace(memberJSON)
|
||||
if memberJSON == "" {
|
||||
return ""
|
||||
}
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 {
|
||||
if names := memberNamesOf(memberJSON); names != nil {
|
||||
return strings.Join(names, "、")
|
||||
}
|
||||
return memberJSON
|
||||
// 空值或坏 JSON:原样写出来(历史上 member_names 被手工写成别的样子也不能丢字)。
|
||||
return strings.TrimSpace(memberJSON)
|
||||
}
|
||||
|
||||
// memberNamesOf 解析 member_names(库里是 JSON 数组文本);空值、坏 JSON、空数组都返回 nil。
|
||||
//
|
||||
// **每一项原样返回**(不去空白、不丢空串):它直接写进老数据的导出件,改动它等于改动老导出件
|
||||
// 的每一个字。新的队员路径在合并文字名单时自己决定怎么去空白(见 participantLabel)。
|
||||
func memberNamesOf(memberJSON string) []string {
|
||||
memberJSON = strings.TrimSpace(memberJSON)
|
||||
if memberJSON == "" {
|
||||
return nil
|
||||
}
|
||||
var raw []string
|
||||
if err := json.Unmarshal([]byte(memberJSON), &raw); err != nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in new issue
Block a user