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
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -1189,6 +1191,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
|
||||
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
|
||||
affectedScopes := make([]entryScope, 0, 2)
|
||||
// 「队员名单」列按号码布认出来、并挂到报名上的队员总数。收尾说一句:这些人会参与个人算分,
|
||||
// 认不出的照旧只是文字名单——不报错,但现场得知道「这份表到底认上了几个人」。
|
||||
linkedMembers := 0
|
||||
for _, table := range tables {
|
||||
index, err := importColumnIndex(table.rows[0])
|
||||
if err != nil {
|
||||
@@ -1447,6 +1452,68 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
squad = ""
|
||||
}
|
||||
|
||||
// 「队员名单」列既是显示用的文字,也能写**号码布**:一格里的每一段先按号码布去
|
||||
// athletes 认人,认得上就把这条报名跟那个人关联起来(entry_athletes),
|
||||
// 认不上的照旧只当文字(现状不变,不报错)。号码布是运动员的唯一标识(devdocs/20 §三)。
|
||||
memberTokens := splitNames(cell("member_names"))
|
||||
link, err := s.importMemberAthletes(ctx, memberTokens)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
continue
|
||||
}
|
||||
for _, name := range link.Repeated {
|
||||
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
|
||||
"第 %d 行:队员名单里的号码布 %s 写了两次,只关联一个人(同一条报名里一个人只占一个位置)", line, name))
|
||||
}
|
||||
|
||||
// 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的
|
||||
// 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。
|
||||
// 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。
|
||||
if athleteID != nil && len(link.Members) > 0 {
|
||||
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
|
||||
"第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:"+
|
||||
"队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人",
|
||||
line, len(link.Members)))
|
||||
link.Members = nil
|
||||
}
|
||||
|
||||
// 一条报名的队员上限与 PUT /api/entries/{id}/athletes 共用:超过就整行拦下,不写一条
|
||||
// 界面上改不动的报名(与联队「一个联队最多 5 个单位」同一条口径)。认不出人的段不计入,
|
||||
// 所以只写姓名的老表一行都不会被它拦下。
|
||||
if len(link.Members) > maxEntryAthletes {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"队员名单里认出了 %d 名运动员,超过一条报名最多 %d 人:请检查这一格是不是把号码布写错了位置",
|
||||
len(link.Members), maxEntryAthletes))
|
||||
continue
|
||||
}
|
||||
|
||||
// 号码布认到别的单位的人:照号码布认(与迁移回填同一口径:号码布是唯一标识,不看单位),
|
||||
// 但要点名说清 —— 团体报名挂在 A 单位、队员却是 B 单位的人,最常见的原因是号码布写错了。
|
||||
// 联队(team_code 写 A+B)的成员单位都算「本行的单位」:队员本来就该来自其中任何一个,
|
||||
// 不该报一个会让人去改正确号码布的假警报。
|
||||
// 这里**不**进冲突面板:那是「要不要用表里的字段覆盖库里这条人员档案」的决策,
|
||||
// 而队员关联一个字段都不改(与 PUT /api/entries/{id}/athletes 一样只认 athleteId)。
|
||||
for _, member := range link.Members {
|
||||
if member.TeamID == teamID || sharesContainTeam(teamShares, member.TeamID) {
|
||||
continue
|
||||
}
|
||||
memberTeam := member.TeamCode
|
||||
if memberTeam == "" {
|
||||
memberTeam = "(无单位)"
|
||||
}
|
||||
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
|
||||
"第 %d 行:队员名单里的号码布 %s 是单位 %s 的 %s,本行报名挂在单位 %s 名下 —— "+
|
||||
"号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错",
|
||||
line, member.Bib, memberTeam, member.Name, teamCode))
|
||||
}
|
||||
|
||||
members := make([]entryAthleteDTO, 0, len(link.Members))
|
||||
for _, member := range link.Members {
|
||||
members = append(members, entryAthleteDTO{AthleteID: member.AthleteID})
|
||||
}
|
||||
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
@@ -1454,7 +1521,8 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
TeamShares: teamShares,
|
||||
Squad: squad,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
MemberNames: memberTokens,
|
||||
Athletes: members,
|
||||
Bib: nilIfEmpty(bib),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
@@ -1466,6 +1534,7 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
linkedMembers += len(members)
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
|
||||
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
|
||||
@@ -1476,7 +1545,10 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
}
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
linkedMembers += len(members)
|
||||
// 队员关联与 status 跨界同一类:它们决定「谁拿到分」,改了就要重算这一组,
|
||||
// 否则会出现「名单里认出了队员、榜上还没他的分」(与 PUT /api/entries/{id}/athletes 同口径)。
|
||||
if outcome.StatusChanged || outcome.MembersChanged {
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
}
|
||||
}
|
||||
@@ -1519,6 +1591,14 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
|
||||
}
|
||||
// 队员名单认了多少人,用既有 notices 通道说一句:号码布认得出人时,这些人就从「文字名单」
|
||||
// 变成了「真实的队员」,会各自拿到个人分与个人奖牌(配了个人通道的项目才上个人榜)。
|
||||
// 认不出的一律照旧只是文字,不报错——人没录档案是常态,这正是降级形态存在的意义。
|
||||
if linkedMembers > 0 {
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"队员名单列按号码布关联了 %d 名队员(号码布是运动员的唯一标识):配了个人通道的项目,他们会各自拿到个人分与个人奖牌;"+
|
||||
"只写姓名、或号码布认不出人的照旧只作文字名单", linkedMembers))
|
||||
}
|
||||
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
|
||||
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
|
||||
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
|
||||
@@ -1574,6 +1654,100 @@ func splitNames(raw string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// importMember 是一格「队员名单」里按号码布认出来的一个人。
|
||||
//
|
||||
// 写进报名报的只有 AthleteID(与 PUT /api/entries/{id}/athletes 同一口径:认人是调用方的事);
|
||||
// 姓名、号码布与单位只用于报告 —— 「认到的是谁、他属于哪个单位」。
|
||||
type importMember struct {
|
||||
AthleteID int64
|
||||
Name string
|
||||
Bib string
|
||||
TeamID int64
|
||||
TeamCode string
|
||||
}
|
||||
|
||||
// importMemberLink 是一格「队员名单」认人的结果。
|
||||
type importMemberLink struct {
|
||||
// Members 按这一格里的书写顺序给出认出来的人(同一个号码布写多次只留一次),
|
||||
// 写进报名的 sort_order 就用这个顺序(与迁移回填「按 member_names 书写顺序」同一口径)。
|
||||
Members []importMember
|
||||
// Repeated 是同一格里写了两次以上、且认得出人的号码布,按首次出现顺序。
|
||||
Repeated []string
|
||||
}
|
||||
|
||||
// importMemberAthletes 把「队员名单」一格里的每一段按**号码布**认成人。
|
||||
//
|
||||
// 口径(devdocs/20 §三,主人 2026-09-14 定的):
|
||||
// - 号码布是运动员的唯一标识(0014 上有部分唯一索引):一段文字与某个运动员的号码布完全相同,
|
||||
// 那就是他 —— **与单位无关**(迁移里的回填 1 同样不看单位),单位不同只作提示;
|
||||
// - 一格里的内容同时能当成号码布与姓名时,以号码布为准:先认号码布,命中了就不再看姓名;
|
||||
// - 认不出的段**保持原样当文字**(member_names 照常保留,现状不变):现场难免有没录档案的人。
|
||||
// 姓名一律不做匹配——同名会认错人,而认错的分已经上了榜(老数据的姓名回填是迁移里的一次性动作,
|
||||
// 且要求同单位唯一,导入不重复这条不确定性)。
|
||||
//
|
||||
// 一格一次查询:格子里的段数最多十几段,但一份表可能有几百行,逐段查会把导入变成几百次往返。
|
||||
func (s *server) importMemberAthletes(ctx context.Context, tokens []string) (importMemberLink, error) {
|
||||
if len(tokens) == 0 {
|
||||
return importMemberLink{}, nil
|
||||
}
|
||||
seen := make(map[string]bool, len(tokens))
|
||||
args := make([]any, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if seen[token] {
|
||||
continue
|
||||
}
|
||||
seen[token] = true
|
||||
args = append(args, token)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT a.bib, a.id, a.name, a.team_id, COALESCE(t.code, '')
|
||||
FROM athletes a LEFT JOIN teams t ON t.id = a.team_id
|
||||
WHERE a.bib IN (`+placeholders(len(args))+`)`, args...)
|
||||
if err != nil {
|
||||
return importMemberLink{}, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
byBib := make(map[string]importMember, len(args))
|
||||
for rows.Next() {
|
||||
var (
|
||||
m importMember
|
||||
bib sql.NullString
|
||||
code string
|
||||
)
|
||||
if err := rows.Scan(&bib, &m.AthleteID, &m.Name, &m.TeamID, &code); err != nil {
|
||||
return importMemberLink{}, internal(err)
|
||||
}
|
||||
m.Bib = bib.String
|
||||
m.TeamCode = code
|
||||
byBib[m.Bib] = m
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return importMemberLink{}, internal(err)
|
||||
}
|
||||
|
||||
var link importMemberLink
|
||||
emitted := make(map[int64]bool)
|
||||
for _, token := range tokens {
|
||||
member, ok := byBib[token]
|
||||
if !ok {
|
||||
// 认不出人:照旧只当文字(member_names 已经把它留在库里了)。
|
||||
continue
|
||||
}
|
||||
if emitted[member.AthleteID] {
|
||||
// 同一个号码布写了两次:同一条报名里一个人只占一个位置(表的主键也这么定),
|
||||
// 这里就地去掉并记下来,由调用方说一句。
|
||||
if !slices.Contains(link.Repeated, member.Bib) {
|
||||
link.Repeated = append(link.Repeated, member.Bib)
|
||||
}
|
||||
continue
|
||||
}
|
||||
emitted[member.AthleteID] = true
|
||||
link.Members = append(link.Members, member)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
|
||||
|
||||
Reference in new issue
Block a user