## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
214 lines
6.3 KiB
Go
214 lines
6.3 KiB
Go
package domain
|
||
|
||
import "testing"
|
||
|
||
// ---------- 通用构造 ----------
|
||
|
||
func ptr(f float64) *float64 { return &f }
|
||
|
||
func boolPtr(b bool) *bool { return &b }
|
||
|
||
// individualTop6 是规则包里的个人项目前六名:10/8/6/3/2/1。
|
||
func individualTop6() ScoringProfile {
|
||
return ScoringProfile{
|
||
ID: 1, Code: "individual_top6", Name: "个人项目前六名",
|
||
AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip,
|
||
Rules: []ScoringRule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}},
|
||
}
|
||
}
|
||
|
||
// teamTop6 是规则包里的团体项目前六名:15/10/8/4/2/1。
|
||
func teamTop6() ScoringProfile {
|
||
return ScoringProfile{
|
||
ID: 2, Code: "team_top6", Name: "团体项目前六名",
|
||
AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip,
|
||
Rules: []ScoringRule{{1, 15}, {2, 10}, {3, 8}, {4, 4}, {5, 2}, {6, 1}},
|
||
}
|
||
}
|
||
|
||
// nineProfile 是测试矩阵 case 1..6 使用的八名表:9/7/6/5/4/3/2/1。
|
||
func nineProfile(tie TieMode) ScoringProfile {
|
||
return ScoringProfile{
|
||
ID: 3, Code: "nine", Name: "八名表",
|
||
AwardRanks: 8, LastPlaceZero: false, TieMode: tie,
|
||
Rules: []ScoringRule{{1, 9}, {2, 7}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}, {8, 1}},
|
||
}
|
||
}
|
||
|
||
func testCategory() EventCategory {
|
||
return EventCategory{ID: 1, EventID: 1, Name: "男子组", Multiplier: 1}
|
||
}
|
||
|
||
func individualEvent(multiplier float64) Event {
|
||
return Event{
|
||
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
|
||
Compare: CompareAsc, RecordEligible: true, Scored: true, AwardMedals: true,
|
||
Multiplier: multiplier, Status: EventStatusPublished,
|
||
}
|
||
}
|
||
|
||
func teamEvent(multiplier float64) Event {
|
||
e := individualEvent(multiplier)
|
||
e.EventType = EventTypeTeam
|
||
e.Code = "E2"
|
||
return e
|
||
}
|
||
|
||
func athleteResultChannel(profileID int64) ScoreChannel {
|
||
return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: profileID}
|
||
}
|
||
|
||
func athleteManualChannel(profileID int64) ScoreChannel {
|
||
return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: profileID}
|
||
}
|
||
|
||
func teamManualChannel(profileID int64) ScoreChannel {
|
||
return ScoreChannel{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: profileID}
|
||
}
|
||
|
||
func teamAggregateChannel(profileID int64) ScoreChannel {
|
||
return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID}
|
||
}
|
||
|
||
// participant 是构造「报名项 + 成绩」的最小描述。
|
||
type participant struct {
|
||
entryID int64
|
||
athleteID int64
|
||
teamID int64
|
||
name string
|
||
value float64
|
||
noValue bool // true 表示这条成绩没有可比数值(如直接录名次的项目)
|
||
rank int
|
||
status ResultStatus
|
||
entryStatus EntryStatus
|
||
state ResultState
|
||
// shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。
|
||
shares []TeamShare
|
||
// members 是团体报名的队员(团队项目的成员运动员);留空 = 只报单位(老数据)。
|
||
members []EntryAthlete
|
||
}
|
||
|
||
func fixture(ps []participant) ([]Entry, []Result) {
|
||
entries := make([]Entry, 0, len(ps))
|
||
results := make([]Result, 0, len(ps))
|
||
for _, p := range ps {
|
||
entries = append(entries, Entry{
|
||
ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID,
|
||
AthleteID: p.athleteID, AthleteName: p.name,
|
||
Status: p.entryStatus, TeamShares: p.shares, Athletes: p.members,
|
||
})
|
||
state := p.state
|
||
if state == "" {
|
||
state = ResultStatePublished
|
||
}
|
||
var value *float64
|
||
if !p.noValue {
|
||
value = ptr(p.value)
|
||
}
|
||
results = append(results, Result{
|
||
ID: p.entryID * 100, EventID: 1, EntryID: p.entryID,
|
||
Rank: p.rank, ResultValue: value, Status: p.status, State: state,
|
||
})
|
||
}
|
||
return entries, results
|
||
}
|
||
|
||
// lineup 生成 n 个参赛者:成绩 1..n,entryID 1..n。
|
||
func lineup(n int) []participant {
|
||
ps := make([]participant, 0, n)
|
||
for i := 1; i <= n; i++ {
|
||
ps = append(ps, participant{
|
||
entryID: int64(i), athleteID: int64(100 + i), teamID: int64(1000 + i),
|
||
name: "选手" + string(rune('A'+i-1)), value: float64(i),
|
||
})
|
||
}
|
||
return ps
|
||
}
|
||
|
||
// lineupValues 生成 n 个参赛者并指定成绩值,用于构造并列。
|
||
func lineupValues(values ...float64) []participant {
|
||
ps := make([]participant, 0, len(values))
|
||
for i, v := range values {
|
||
ps = append(ps, participant{
|
||
entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1000 + i + 1),
|
||
name: "选手" + string(rune('A'+i)), value: v,
|
||
})
|
||
}
|
||
return ps
|
||
}
|
||
|
||
func mustScore(t *testing.T, in EventScoreInput) EventScore {
|
||
t.Helper()
|
||
es, err := ScoreEvent(in)
|
||
if err != nil {
|
||
t.Fatalf("ScoreEvent 返回错误: %v", err)
|
||
}
|
||
return es
|
||
}
|
||
|
||
func pointsByEntry(ledger []LedgerEntry) map[int64]float64 {
|
||
out := make(map[int64]float64)
|
||
for _, e := range ledger {
|
||
if e.Voided {
|
||
continue
|
||
}
|
||
out[e.EntryID] += e.Points
|
||
}
|
||
return out
|
||
}
|
||
|
||
func rankByEntry(ranked []RankedResult) map[int64]int {
|
||
out := make(map[int64]int, len(ranked))
|
||
for _, r := range ranked {
|
||
out[r.EntryID] = r.Rank
|
||
}
|
||
return out
|
||
}
|
||
|
||
func pointsByTeam(ledger []LedgerEntry) map[int64]float64 {
|
||
out := make(map[int64]float64)
|
||
for _, e := range ledger {
|
||
if e.Voided {
|
||
continue
|
||
}
|
||
out[e.TeamID] += e.Points
|
||
}
|
||
return out
|
||
}
|
||
|
||
// assertEntryPoints 断言 entryID 1..len(want) 的积分依次等于 want;缺键按 0 处理。
|
||
func assertEntryPoints(t *testing.T, got map[int64]float64, want []float64) {
|
||
t.Helper()
|
||
for i, w := range want {
|
||
id := int64(i + 1)
|
||
if !nearlyEqual(got[id], w) {
|
||
t.Errorf("entry %d 积分 = %v,期望 %v", id, got[id], w)
|
||
}
|
||
}
|
||
}
|
||
|
||
func testTeams(ids ...int64) []Team {
|
||
out := make([]Team, 0, len(ids))
|
||
for i, id := range ids {
|
||
out = append(out, Team{
|
||
ID: id, Code: string(rune('A' + i)), Name: "单位" + string(rune('A'+i)), Active: true,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func ledgerFor(teamID int64, points float64) LedgerEntry {
|
||
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points, CountToTeam: true}
|
||
}
|
||
|
||
func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry {
|
||
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points, CountToTeam: true}
|
||
}
|
||
|
||
// ledgerForPersonal 是「不计入单位总分」的流水:只记在个人头上(如「个人+团体」项目的个人通道)。
|
||
func ledgerForPersonal(teamID, athleteID int64, points float64) LedgerEntry {
|
||
e := ledgerForAthlete(teamID, athleteID, points)
|
||
e.CountToTeam = false
|
||
return e
|
||
}
|