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
@@ -0,0 +1,267 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「团体报名的队员身份」在引擎里的口径(devdocs/20):
|
||||
//
|
||||
// - 一条团体报名(athlete_id 为空)可以挂多个队员;个人通道给**每个队员各一份完整的**分
|
||||
// (不是按份额拆,与联队 entry_team_shares 的「拆」相反),个人奖牌也是每人各一枚;
|
||||
// - **单位维度一份都不多**:单位总分与单位奖牌仍然只算这条报名一次,由通道开关
|
||||
// count_to_team / count_to_team_medals 控制;
|
||||
// - 没有队员关联的老数据(Entry.Athletes 为空)逐字节不变。
|
||||
|
||||
// memberEntry 造一条带队员的团体报名。
|
||||
func memberEntry(entryID, teamID int64, value float64, members ...EntryAthlete) participant {
|
||||
return participant{entryID: entryID, teamID: teamID, value: value, members: members}
|
||||
}
|
||||
|
||||
// twoDoubles 是「一个单位派两个人打双打 + 另一个单位一个人」的最小现场形态:
|
||||
// 成绩越小越好(compare=asc,与 100 米那种打法一致)。
|
||||
func twoDoubles() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
memberEntry(1, 1001, 11.2,
|
||||
EntryAthlete{AthleteID: 201, Name: "队员一"},
|
||||
EntryAthlete{AthleteID: 202, Name: "队员二"}),
|
||||
{entryID: 2, teamID: 1002, value: 11.5},
|
||||
})
|
||||
}
|
||||
|
||||
// twoDoublesLegacy 是升级前的老数据形态:同样两条报名、同样的成绩,但队员只有姓名文字
|
||||
// (认不出是哪个人),也就是回填不出关联或干脆没回填的那些报名。
|
||||
func twoDoublesLegacy() ([]Entry, []Result) {
|
||||
entries, results := twoDoubles()
|
||||
entries[0].Athletes = nil
|
||||
return entries, results
|
||||
}
|
||||
|
||||
// doublesEventInput 是双打的计分输入:团体项目 + 一个按成绩排名的个人通道。
|
||||
// teamFlagsOff 打开通道的两个单位开关「都不计入」(现场的双打口径)。
|
||||
func doublesEventInput(entries []Entry, results []Result, teamFlagsOff bool) EventScoreInput {
|
||||
ch := athleteResultChannel(9)
|
||||
if teamFlagsOff {
|
||||
ch.CountToTeam = boolPtr(false)
|
||||
ch.CountToTeamMedals = boolPtr(false)
|
||||
}
|
||||
return EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{ch},
|
||||
Profiles: map[int64]ScoringProfile{9: individualTop6()},
|
||||
Entries: entries, Results: results,
|
||||
}
|
||||
}
|
||||
|
||||
// 双打:两个队员各拿一份完整的个人分(第 1 名 10 分 → 两人各 10 分,不是各 5 分),
|
||||
// 单位总分仍然只有一份 10 分;个人奖牌每人各一枚,单位奖牌只有一枚。
|
||||
func TestTeamEntryMembersScoreIndividually(t *testing.T) {
|
||||
entries, results := twoDoubles()
|
||||
es := mustScore(t, doublesEventInput(entries, results, false))
|
||||
|
||||
// 名次明细:报名项自己那一份(单位维度认它)+ 每个队员一份个人副本。
|
||||
wantRanked := []string{
|
||||
"key=e:1 scope=athlete basis=result team=1001 athlete=0 rank=1 points=10",
|
||||
"key=e:1:a:201 scope=athlete basis=result team=1001 athlete=201 rank=1 points=10",
|
||||
"key=e:1:a:202 scope=athlete basis=result team=1001 athlete=202 rank=1 points=10",
|
||||
"key=e:2 scope=athlete basis=result team=1002 athlete=0 rank=2 points=8",
|
||||
}
|
||||
if got := rankedSummary(es.Ranked); strings.Join(got, "\n") != strings.Join(wantRanked, "\n") {
|
||||
t.Fatalf("名次明细不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantRanked, "\n"))
|
||||
}
|
||||
|
||||
// 流水:单位那一份照旧(count_to_team=true),队员的个人分是「只记个人」的额外流水——
|
||||
// 它们 count_to_team=false,所以既不会让单位总分变成两份,也不会进单位奖牌。
|
||||
wantLedger := []string{
|
||||
"team=1001 athlete=0 entry=1 result=100 points=10 countToTeam=true",
|
||||
"team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false",
|
||||
"team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false",
|
||||
"team=1002 athlete=0 entry=2 result=200 points=8 countToTeam=true",
|
||||
}
|
||||
if got := ledgerSummary(es.Ledger); strings.Join(got, "\n") != strings.Join(wantLedger, "\n") {
|
||||
t.Fatalf("流水不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantLedger, "\n"))
|
||||
}
|
||||
|
||||
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
|
||||
|
||||
// 单位总分只算一次:10 分(不是 30 分),金牌一枚(不是三枚)。
|
||||
rows := BuildTeamStandings(in)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("积分榜应有两行,得到 %+v", rows)
|
||||
}
|
||||
if rows[0].TeamID != 1001 || !nearlyEqual(rows[0].Points, 10) ||
|
||||
rows[0].TeamOnlyPoints != 10 || rows[0].IndividualPoints != 0 || rows[0].Gold != 1 || rows[0].Medals != 1 {
|
||||
t.Fatalf("双打单位那一行 = %+v,期望总分 10(仅单位 10、个人 0)、1 枚金牌(不进个人分)", rows[0])
|
||||
}
|
||||
if rows[1].TeamID != 1002 || rows[1].Points != 8 || rows[1].Silver != 1 {
|
||||
t.Fatalf("对照单位那一行 = %+v,期望 8 分 1 银", rows[1])
|
||||
}
|
||||
medals := BuildMedalStandings(in)
|
||||
if medals[0].TeamID != 1001 || medals[0].Gold != 1 || medals[0].Total != 1 {
|
||||
t.Fatalf("单位奖牌榜第一行 = %+v,期望单位 1001 只记 1 枚金牌", medals[0])
|
||||
}
|
||||
|
||||
// 个人榜:两个队员各一份满分、各一枚金牌。
|
||||
athletes := BuildAthleteStandings(in)
|
||||
wantAthletes := []string{
|
||||
"athlete=201 team=1001 points=10 1/0/0 medals=1",
|
||||
"athlete=202 team=1001 points=10 1/0/0 medals=1",
|
||||
}
|
||||
got := make([]string, 0, len(athletes))
|
||||
for _, row := range athletes {
|
||||
got = append(got, fmt.Sprintf("athlete=%d team=%d points=%v %d/%d/%d medals=%d",
|
||||
row.AthleteID, row.TeamID, row.Points, row.Gold, row.Silver, row.Bronze, row.Medals))
|
||||
}
|
||||
// 两人同分同牌,排序落到姓名(队员一 < 队员二),顺序稳定。
|
||||
if strings.Join(got, "\n") != strings.Join(wantAthletes, "\n") {
|
||||
t.Fatalf("个人榜不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantAthletes, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// 现场的双打口径(通道两个单位开关都关掉):队员照拿个人分与个人奖牌,单位一分一牌都不拿。
|
||||
func TestTeamEntryMembersWithTeamFlagsOff(t *testing.T) {
|
||||
entries, results := twoDoubles()
|
||||
es := mustScore(t, doublesEventInput(entries, results, true))
|
||||
|
||||
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
|
||||
for _, row := range BuildTeamStandings(in) {
|
||||
if row.Points != 0 || row.Medals != 0 {
|
||||
t.Errorf("单位 %d 在「都不计入」下应为 0 分 0 牌,得到 %+v", row.TeamID, row)
|
||||
}
|
||||
}
|
||||
for _, row := range BuildMedalStandings(in) {
|
||||
if row.Total != 0 {
|
||||
t.Errorf("单位 %d 在「都不计入」下不该有奖牌,得到 %+v", row.TeamID, row)
|
||||
}
|
||||
}
|
||||
athletes := BuildAthleteStandings(in)
|
||||
if len(athletes) != 2 || athletes[0].Points != 10 || athletes[0].Gold != 1 ||
|
||||
athletes[1].Points != 10 || athletes[1].Gold != 1 {
|
||||
t.Fatalf("队员的个人分与个人奖牌照发,得到 %+v", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 联队 + 队员同时存在(A+B 联队派两个人打双打):两套机制各管一维,不许互相污染——
|
||||
// 单位那一份仍按份额**拆**(各 5 分)、奖牌各 1 枚;队员各拿**一整份**(各 10 分,不是各 5 分)。
|
||||
func TestJointTeamWithMembersKeepsSharesAndCopiesSeparate(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, teamID: 1001, value: 11.2,
|
||||
shares: []TeamShare{{TeamID: 1001, Weight: 1}, {TeamID: 1002, Weight: 1}},
|
||||
members: []EntryAthlete{{AthleteID: 201, Name: "队员一"}, {AthleteID: 202, Name: "队员二"}}},
|
||||
})
|
||||
es := mustScore(t, doublesEventInput(entries, results, false))
|
||||
|
||||
// 流水:单位那两份各 5 分(按份额拆),队员那两份各 10 分(整份,只记个人)。
|
||||
ledger := ledgerSummary(es.Ledger)
|
||||
want := []string{
|
||||
"team=1001 athlete=0 entry=1 result=100 points=5 countToTeam=true",
|
||||
"team=1002 athlete=0 entry=1 result=100 points=5 countToTeam=true",
|
||||
"team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false",
|
||||
"team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false",
|
||||
}
|
||||
if strings.Join(ledger, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("联队 + 队员的流水不对:\n得到 %s期望 %s", strings.Join(ledger, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
|
||||
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
|
||||
for _, row := range BuildMedalStandings(in) {
|
||||
if row.Gold != 1 || row.Total != 1 {
|
||||
t.Errorf("联队单位 %d 应各记 1 枚金牌(队员不重复发牌),得到 %+v", row.TeamID, row)
|
||||
}
|
||||
}
|
||||
athletes := BuildAthleteStandings(in)
|
||||
if len(athletes) != 2 || athletes[0].Points != 10 || athletes[1].Points != 10 {
|
||||
t.Fatalf("联队里的队员各拿一整份个人分,得到 %+v", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 硬门槛:挂上队员之后,**单位维度逐字节不变**。
|
||||
//
|
||||
// 同一份成绩算两遍:一遍是升级前的老数据形态(只有姓名文字、认不出人),一遍是升级后
|
||||
// 回填出队员身份。单位那一份名次、流水、总分、奖牌必须一模一样——队员的分只走个人维度,
|
||||
// 绝不能渗进单位总分或单位奖牌(那是本单最容易错的地方)。
|
||||
func TestTeamEntryMembersKeepTeamDimensionByteIdentical(t *testing.T) {
|
||||
legacyEntries, legacyResults := twoDoublesLegacy()
|
||||
without := mustScore(t, doublesEventInput(legacyEntries, legacyResults, false))
|
||||
|
||||
entries, results := twoDoubles()
|
||||
with := mustScore(t, doublesEventInput(entries, results, false))
|
||||
|
||||
before := teamDimensionDump(without)
|
||||
after := teamDimensionDump(with)
|
||||
if before != after {
|
||||
t.Fatalf("挂上队员后单位维度变了:\n之前\n%s之后\n%s", before, after)
|
||||
}
|
||||
if !strings.Contains(before, "单位榜") || !strings.Contains(before, "单位奖牌榜") {
|
||||
t.Fatalf("对拍文本里必须有单位榜与单位奖牌榜,否则等于两份空文本相等:\n%s", before)
|
||||
}
|
||||
// 防空转:老形态的个人维度确实是空的(队员认不出来就上不了个人榜)。
|
||||
if got := len(BuildAthleteStandings(StandingsInput{Ledger: without.Ledger, Ranked: without.Ranked})); got != 0 {
|
||||
t.Fatalf("没有队员关联时个人榜应为空,得到 %d 行", got)
|
||||
}
|
||||
if got := len(BuildAthleteStandings(StandingsInput{Ledger: with.Ledger, Ranked: with.Ranked})); got != 2 {
|
||||
t.Fatalf("挂上队员后个人榜应有两个队员,得到 %d 行", got)
|
||||
}
|
||||
}
|
||||
|
||||
// teamDimensionDump 把「单位维度」的全部事实拼成稳定文本:单位那一份流水、单位总分、
|
||||
// 单位榜与单位奖牌榜。队员的个人流水不进这份文本(它不属于单位维度)。
|
||||
func teamDimensionDump(es EventScore) string {
|
||||
var lines []string
|
||||
for _, l := range es.Ledger {
|
||||
if l.AthleteID != 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("流水 team=%d entry=%d result=%d points=%v countToTeam=%v",
|
||||
l.TeamID, l.EntryID, l.ResultID, l.Points, l.CountToTeam))
|
||||
}
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.AthleteID != 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("名次 key=%s scope=%s team=%d rank=%d points=%v shares=%s",
|
||||
rr.SubjectKey, rr.Scope, rr.TeamID, rr.Rank, rr.Points, formatShares(rr.Shares)))
|
||||
}
|
||||
in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked}
|
||||
for _, weight := range []float64{1, 0.5} {
|
||||
for _, exclude := range []bool{false, true} {
|
||||
for _, row := range BuildTeamStandings(StandingsInput{
|
||||
Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked,
|
||||
IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude,
|
||||
}) {
|
||||
lines = append(lines, fmt.Sprintf("单位榜 w=%v ex=%v team=%d pts=%v only=%v indiv=%v %d/%d/%d",
|
||||
weight, exclude, row.TeamID, row.Points, row.TeamOnlyPoints, row.IndividualPoints,
|
||||
row.Gold, row.Silver, row.Bronze))
|
||||
}
|
||||
for _, row := range BuildMedalStandings(StandingsInput{
|
||||
Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked,
|
||||
IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude,
|
||||
}) {
|
||||
lines = append(lines, fmt.Sprintf("单位奖牌榜 w=%v ex=%v team=%d %d/%d/%d total=%d",
|
||||
weight, exclude, row.TeamID, row.Gold, row.Silver, row.Bronze, row.Total))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
// rankedSummary 打印名次明细里「谁、什么名次、多少分」这几件事。
|
||||
func rankedSummary(ranked []RankedResult) []string {
|
||||
out := make([]string, 0, len(ranked))
|
||||
for _, rr := range ranked {
|
||||
out = append(out, fmt.Sprintf("key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v",
|
||||
rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ledgerSummary 打印流水的归属与是否计入单位总分。
|
||||
func ledgerSummary(ledger []LedgerEntry) []string {
|
||||
out := make([]string, 0, len(ledger))
|
||||
for _, l := range ledger {
|
||||
out = append(out, fmt.Sprintf("team=%d athlete=%d entry=%d result=%d points=%v countToTeam=%v",
|
||||
l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.Points, l.CountToTeam))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -84,6 +84,8 @@ type participant struct {
|
||||
state ResultState
|
||||
// shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。
|
||||
shares []TeamShare
|
||||
// members 是团体报名的队员(团队项目的成员运动员);留空 = 只报单位(老数据)。
|
||||
members []EntryAthlete
|
||||
}
|
||||
|
||||
func fixture(ps []participant) ([]Entry, []Result) {
|
||||
@@ -93,7 +95,7 @@ func fixture(ps []participant) ([]Entry, []Result) {
|
||||
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,
|
||||
Status: p.entryStatus, TeamShares: p.shares, Athletes: p.members,
|
||||
})
|
||||
state := p.state
|
||||
if state == "" {
|
||||
|
||||
@@ -69,6 +69,14 @@ type RankedResult struct {
|
||||
AwardMedals bool
|
||||
CountToMedals bool
|
||||
CountToTeamMedals bool
|
||||
// MemberCopy 为 true 表示这条名次是「团体报名的队员各拿一份」的个人副本(devdocs/20):
|
||||
// 分记到 AthleteID 头上(个人榜),但**不进单位维度**——流水恒为 count_to_team=false,
|
||||
// 单位奖牌按报名项去重时也只认报名项那一份。
|
||||
//
|
||||
// 它只在「引擎产出 → 落库」这一段有意义:channel_ranks 不存这个开关,落库后认出这些行
|
||||
// 靠 subject_key 的 'e:<报名项>:a:<运动员>' 形状与 athlete_id 关联,而下游(榜单、导出、
|
||||
// 快照)本来就不需要它——它们看的是「按报名项去重」与 athlete_id 这两件既有事实。
|
||||
MemberCopy bool
|
||||
}
|
||||
|
||||
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
|
||||
@@ -430,12 +438,65 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro
|
||||
for _, g := range groups {
|
||||
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
|
||||
for _, c := range g.members {
|
||||
out = append(out, rankedResult(in, ch, c, g.startRank, pts))
|
||||
out = append(out, entryRankedResults(in, ch, c, g.startRank, pts)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entryRankedResults 产出一条报名的**全部**名次明细:第一条是报名项自己那份(单位维度只认它,
|
||||
// 老数据也只有这一条),后面是每个队员的个人副本(团队报名才有,每人一份完整的个人分)。
|
||||
//
|
||||
// 顺序是有意的:报名项那一份排在最前。channel_ranks 按 (…, 名次, id) 读出、奖牌去重按报名项
|
||||
// 取同键里的第一条,于是「单位维度读到的是哪一份」与升级前完全一致。
|
||||
func entryRankedResults(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) []RankedResult {
|
||||
base := rankedResult(in, ch, c, rank, points)
|
||||
members := memberAthletes(ch, c.entry)
|
||||
if len(members) == 0 {
|
||||
return []RankedResult{base}
|
||||
}
|
||||
out := make([]RankedResult, 0, len(members)+1)
|
||||
out = append(out, base)
|
||||
for _, m := range members {
|
||||
out = append(out, memberCopy(base, m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memberAthletes 判断这条名次要不要给队员各发一份:只有**个人通道**(scope=athlete)才发。
|
||||
// 团体通道(scope=team)的名次本来就记在单位头上,队员不参与;带运动员的报名(个人报名)
|
||||
// 自己就是那个人的身份,没有队员这回事。
|
||||
func memberAthletes(ch ScoreChannel, e Entry) []EntryAthlete {
|
||||
if ch.Scope != ScopeAthlete || e.AthleteID != 0 {
|
||||
return nil
|
||||
}
|
||||
return e.Athletes
|
||||
}
|
||||
|
||||
// memberCopy 把「报名项那一份名次」复制成某个队员的个人分:分数、名次、归属单位与份额全部照抄,
|
||||
// 只把身份换成这个队员,并打上 MemberCopy 标记(落库时按它走「只记个人」的流水)。
|
||||
//
|
||||
// 照抄 TeamID 与 Shares 是**必须的**:单位维度按报名项去重(standings.medalKeyOf),
|
||||
// 无论同键里的哪一份胜出,单位拿到的分与牌都是同一份;换成队员自己的单位或另造份额,
|
||||
// 就会在「谁在去重里胜出」这件事上分叉,联队报名尤其容易给错单位记牌。
|
||||
func memberCopy(base RankedResult, m EntryAthlete) RankedResult {
|
||||
cp := base
|
||||
cp.MemberCopy = true
|
||||
cp.AthleteID = m.AthleteID
|
||||
cp.AthleteName = m.Name
|
||||
cp.SubjectKey = memberSubjectKey(base.EntryID, m.AthleteID)
|
||||
return cp
|
||||
}
|
||||
|
||||
// memberSubjectKey 是队员个人副本的主体标识:'e:<报名项>:a:<运动员>'。
|
||||
//
|
||||
// 必须带上报名项:同一个运动员可以同时出现在同组别的两条报名里(例如一个单位的一队与二队
|
||||
// 各带了他),只用 'a:<运动员>' 会撞 channel_ranks 的 (通道, 组别, 主体) 唯一索引,
|
||||
// 整次发布直接 500。既有名次键(e: / a: / t: / ch:)一个字符都没变,老行不受影响。
|
||||
func memberSubjectKey(entryID, athleteID int64) string {
|
||||
return fmt.Sprintf("e:%d:a:%d", entryID, athleteID)
|
||||
}
|
||||
|
||||
// groupPoints 计算一组并列者拿到的分。
|
||||
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1;
|
||||
// lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。
|
||||
@@ -684,6 +745,9 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
|
||||
// 0 分不产生流水,避免流水表被零分噪声撑大。
|
||||
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
|
||||
//
|
||||
// 队员的个人副本走另一条路(memberLedger):每个队员一整份分、只记个人(count_to_team=false),
|
||||
// 于是单位总分与单位奖牌一个字节都不动——那是本单最容易错的地方,见 memberLedger。
|
||||
//
|
||||
// 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0),
|
||||
// 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id,
|
||||
// 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」
|
||||
@@ -694,10 +758,6 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
if rr.Points == 0 {
|
||||
continue
|
||||
}
|
||||
athleteID := int64(0)
|
||||
if rr.Scope == ScopeAthlete {
|
||||
athleteID = rr.AthleteID
|
||||
}
|
||||
sourceID := rr.ResultID
|
||||
if sourceID == 0 {
|
||||
sourceID = rr.EntryID
|
||||
@@ -705,6 +765,14 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
if sourceID == 0 {
|
||||
sourceID = ch.ID
|
||||
}
|
||||
if rr.MemberCopy {
|
||||
out = append(out, memberLedger(rr, in, ch, sourceID))
|
||||
continue
|
||||
}
|
||||
athleteID := int64(0)
|
||||
if rr.Scope == ScopeAthlete {
|
||||
athleteID = rr.AthleteID
|
||||
}
|
||||
split := splitSharePoints(rr.Points, rr.Shares)
|
||||
for i, s := range rr.Shares {
|
||||
if split[i] == 0 {
|
||||
@@ -728,6 +796,29 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
return out
|
||||
}
|
||||
|
||||
// memberLedger 是队员个人副本的流水:**只记个人**(CountToTeam 恒为 false),
|
||||
// 每个队员一整份分,不按份额拆(联队那套是「拆」,这里是「复制」,别抄混)。
|
||||
//
|
||||
// 为什么恒为 false、而不是照抄通道开关:单位维度只认报名项那一份流水(它照抄通道开关),
|
||||
// 队员这两份若也计入,单位总分就会变成 N 份(双打就是双倍),单位奖牌同理。
|
||||
// SumByTeam 遇到 count_to_team=0 直接跳过,所以单位总分、单位明细、单位奖牌全都不会看到它们;
|
||||
// 而 SumByAthlete 不看这个开关,队员的个人分照拿(这也正是「双打不进单位、但上个人榜」的实现)。
|
||||
func memberLedger(rr RankedResult, in EventScoreInput, ch ScoreChannel, sourceID int64) LedgerEntry {
|
||||
return LedgerEntry{
|
||||
TeamID: rr.TeamID,
|
||||
AthleteID: rr.AthleteID,
|
||||
EventID: in.Event.ID,
|
||||
CategoryID: in.Category.ID,
|
||||
EntryID: rr.EntryID,
|
||||
ResultID: rr.ResultID,
|
||||
ChannelID: ch.ID,
|
||||
SourceType: SourceResult,
|
||||
SourceID: sourceID,
|
||||
Points: rr.Points,
|
||||
CountToTeam: false,
|
||||
}
|
||||
}
|
||||
|
||||
// subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径):
|
||||
// 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位),
|
||||
// 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。
|
||||
|
||||
@@ -298,9 +298,12 @@ func medalKeyOf(rr RankedResult, index int) medalKey {
|
||||
return k
|
||||
}
|
||||
|
||||
// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细去重成「每个主体一条」:
|
||||
// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细按 keyOf 去重成「每个主体一条」:
|
||||
// 只保留优先级最高的通道名次(个人通道优先),否则双通道项目一条成绩会拿两枚奖牌。
|
||||
func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
|
||||
//
|
||||
// 去重键由调用方给:**单位维度按报名项**(medalKeyOf,一条报名只记一枚牌),
|
||||
// **个人维度按报名项 + 运动员**(athleteMedalKeyOf,团体报名的队员各记各的)。
|
||||
func pickMedalChannels(ranked []RankedResult, excludeTeam bool, keyOf func(RankedResult, int) medalKey) []RankedResult {
|
||||
best := make(map[medalKey]RankedResult, len(ranked))
|
||||
order := make([]medalKey, 0, len(ranked))
|
||||
for i, rr := range ranked {
|
||||
@@ -310,7 +313,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
|
||||
if excludeTeam && rr.Scope == ScopeTeam {
|
||||
continue
|
||||
}
|
||||
k := medalKeyOf(rr, i)
|
||||
k := keyOf(rr, i)
|
||||
cur, ok := best[k]
|
||||
if !ok {
|
||||
best[k] = rr
|
||||
@@ -328,10 +331,31 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// athleteMedalKeyOf 是个人维度的奖牌去重键:在单位维度的键上再带上运动员。
|
||||
//
|
||||
// 团体报名的队员各有一份个人副本,它们与报名项那一份共享 entry_id——只按报名项去重的话,
|
||||
// 三份会并成一条(且胜出的是排在最前的报名项那份,AthleteID=0,个人维度根本不看它),
|
||||
// 队员的个人奖牌会**一枚都发不出来**。带上运动员之后:同一报名项同一运动员仍旧只记一枚
|
||||
// (多通道项目不会重复发牌),不同队员各记各的。
|
||||
//
|
||||
// 只对「有报名项」的名次加这一维:没有报名项的聚合名次本来就用主体区分(medalKeyOf 已处理),
|
||||
// 老数据的名次构不成同一个键下的多条,行为逐字节不变。
|
||||
func athleteMedalKeyOf(rr RankedResult, index int) medalKey {
|
||||
k := medalKeyOf(rr, index)
|
||||
if k.entryID != 0 && rr.AthleteID != 0 {
|
||||
k.scope, k.subject = ScopeAthlete, rr.AthleteID
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:每个份额各记**一枚整牌**,
|
||||
// 联队拿到的金牌因此两个成员单位各记 1 枚(不是各 0.5 枚,口径见 TeamShare.Medal)。
|
||||
// 份额里的 Medal 显式关掉时该单位不记这枚牌(积分份额 Weight 不参与奖牌计算)。
|
||||
//
|
||||
// 去重按**报名项**(medalKeyOf):一条报名在同一小项同一组别只记一枚牌,而团体报名的队员
|
||||
// 个人副本与报名项那一份共享 entry_id,因此**不会把单位奖牌数放大成队员人数倍**
|
||||
// ——这是本单最容易错的地方,由 TestTeamEntryMembersKeepTeamDimensionByteIdentical 钉住。
|
||||
//
|
||||
// 两层开关(devdocs 17 §4.5):
|
||||
// - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜;
|
||||
// - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。
|
||||
@@ -339,7 +363,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
|
||||
// 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。
|
||||
func teamMedals(in StandingsInput) map[int64]medalTally {
|
||||
out := make(map[int64]medalTally)
|
||||
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
|
||||
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals, medalKeyOf) {
|
||||
if rr.Scope != ScopeTeam && !rr.CountToTeamMedals {
|
||||
continue
|
||||
}
|
||||
@@ -368,9 +392,12 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
|
||||
|
||||
// athleteMedals 只统计个人项目(scope=athlete)的奖牌;
|
||||
// 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。
|
||||
//
|
||||
// 去重按**报名项 + 运动员**(athleteMedalKeyOf):团体报名的每个队员各记一枚自己的奖牌
|
||||
// (他们共享同一条报名项,只按报名项去重会把队员的奖牌并成一枚、甚至一枚都不发)。
|
||||
func athleteMedals(in StandingsInput) map[int64]medalTally {
|
||||
out := make(map[int64]medalTally)
|
||||
for _, rr := range pickMedalChannels(in.Ranked, false) {
|
||||
for _, rr := range pickMedalChannels(in.Ranked, false, athleteMedalKeyOf) {
|
||||
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -311,6 +311,18 @@ func (s TeamShare) CountsMedal() bool {
|
||||
return s.Medal == nil || *s.Medal
|
||||
}
|
||||
|
||||
// EntryAthlete 是团体报名里的一名队员(团队成员),身份靠运动员档案(AthleteID)确立;
|
||||
// 人工认人时以**号码布**为准(姓名会重名,号码布不会,见迁移 0014 与 devdocs/20)。
|
||||
//
|
||||
// 与 TeamShare 对称但语义**相反**(devdocs/20 §二):TeamShare 是「一条报名 → 多个单位、
|
||||
// 按权重**拆**分」,EntryAthlete 是「一条报名 → 多个运动员、每人各拿**整份**分」。
|
||||
// 抄联队那套「拆」会把双打两个人的分各砍一半,这正是本单最容易抄错的地方。
|
||||
type EntryAthlete struct {
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
// Name 是冗余姓名,与 Entry.AthleteName 同一个用途:引擎不必回查库就能产出个人榜的那一行。
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Entry 报名项:某单位派某人/某队参加某小项。TeamID 是**主单位**(联队时也保留它,
|
||||
// 报名项的唯一键、榜单兜底、导入去重都还认这一个),联队的其余成员单位在 TeamShares 里。
|
||||
type Entry struct {
|
||||
@@ -328,6 +340,10 @@ type Entry struct {
|
||||
// TeamShares 是这条报名的归属份额配置:空 = 主单位独占 100%(老数据、普通报名),
|
||||
// 多条 = 联队。引擎只读不改,取分的唯一入口是 ResolveShares。
|
||||
TeamShares []TeamShare `json:"teamShares,omitempty"`
|
||||
// Athletes 是这条报名的队员(团队 / 对抗项目的成员),空 = 只报单位(升级前的老数据、
|
||||
// 以及「人没录档案只登记姓名」的降级形态)。只对纯团体报名(AthleteID == 0)有意义。
|
||||
// 队员各拿一份完整的个人分与个人奖牌;单位维度仍然只算这条报名一次。
|
||||
Athletes []EntryAthlete `json:"athletes,omitempty"`
|
||||
}
|
||||
|
||||
// Result 成绩;一条报名项一条成绩。
|
||||
|
||||
@@ -128,6 +128,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
// 队员关联(devdocs/20):整份名单读一次,逐行按 entry_id 取。
|
||||
members, err := loadEntryMembers(db)
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
|
||||
@@ -206,7 +211,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
|
||||
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
|
||||
}
|
||||
row.Name = participantName(athleteName, memberJSON)
|
||||
row.Name = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
if heat.Valid {
|
||||
row.Heat = strconv.FormatInt(heat.Int64, 10)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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, "、")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是「没有队员关联的报名,导出件逐行一致」这条硬门槛的对拍工具(devdocs/20 的导出侧)。
|
||||
//
|
||||
// 做法与 squad_dump_test.go 完全一致:把每一份导出件的每一张工作表、每一个单元格 dump 成文本
|
||||
// (`工作簿|工作表|行|列|值`),先落盘到 EXPORT_MEMBERS_DUMP 指定的路径(不设时写 t.TempDir(),
|
||||
// 平时只当一次「全部导出件都能加载」的冒烟)。把**同一个文件**放到改动前的代码上再跑一次
|
||||
// (对拍目录里的那份树只补这个用例、不补源码改动),两份 dump `diff` 必须为空。
|
||||
//
|
||||
// 为什么这份数据里没有 entry_athletes 行:老库升级后绝大多数报名都没有队员关联(只报单位的
|
||||
// 团体报名、个人报名、只登记文字名单的报名),本单不许让它们的导出件发生任何变化——包括
|
||||
// 因为「多查了一张表」「多拼了一个空串」而变的那一种。挂上队员之后的变化由下面的灵敏度自检盯着。
|
||||
|
||||
// TestMemberlessExportDumpIsUnchanged 见文件头。数据集复用 seedSquadDumpEvent:它覆盖了
|
||||
// 个人小项(含团体行回落成员名单)、纯团体报名、联队份额与队名,且**一条 entry_athletes 都没有**。
|
||||
func TestMemberlessExportDumpIsUnchanged(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedSquadDumpEvent(t, db)
|
||||
|
||||
opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}}
|
||||
memberless := dumpAllWorkbooks(t, db, opts)
|
||||
|
||||
// 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。
|
||||
path := os.Getenv("EXPORT_MEMBERS_DUMP")
|
||||
if path == "" {
|
||||
path = filepath.Join(t.TempDir(), "members-empty-dump.txt")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(memberless), 0o644); err != nil {
|
||||
t.Fatalf("写出 dump: %v", err)
|
||||
}
|
||||
t.Logf("无队员 dump:%s(%d 个单元格)", path, strings.Count(memberless, "\n"))
|
||||
|
||||
// 灵敏度自检:给双打那条报名挂上一名队员(另一名仍然是文字名单里的人),重新 dump,两次必须不同。
|
||||
// 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到队员列——那就白对拍了。
|
||||
// 在对拍用的旧代码上这条自检本来就会红(旧代码根本不读 entry_athletes),正说明两份 dump 的
|
||||
// 差异只可能来自这次改动。
|
||||
stmts := []string{
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A001')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order)
|
||||
SELECT e.id, a.id, 0 FROM entries e, athletes a
|
||||
WHERE e.event_id = 2 AND e.member_names LIKE '%王磊%' AND a.name = '王磊'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
withMembers := dumpAllWorkbooks(t, db, opts)
|
||||
if withMembers == memberless {
|
||||
t.Fatal("给报名挂上队员后 dump 没变:这份对拍看不见队员列,对拍结论无效")
|
||||
}
|
||||
// 关联上的写「姓名(号码布)」,没关联上的文字名字接在后面(人没录档案的临时队员照旧在名单里)。
|
||||
if !strings.Contains(withMembers, "王磊(A001)、赵雷") {
|
||||
t.Fatal("挂队员后的 dump 里找不到「王磊(A001)、赵雷」:队员没有按「姓名(号码布)」写进导出件")
|
||||
}
|
||||
if strings.Contains(memberless, "(A001)") {
|
||||
t.Fatal("无队员的 dump 里不该出现号码布写法")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「团体报名挂上队员之后,成绩表里这条报名仍然只占一行」(devdocs/20 的导出侧边界)。
|
||||
//
|
||||
// 原因:一条报名在同一通道下会有 1 + N 行 channel_ranks(报名项自己那份 + 每个队员一份个人副本),
|
||||
// 成绩表按 (通道, 报名, 组别) JOIN 名次时会把这条报名印 N+1 遍——现场看到的就是「双打报了三次」。
|
||||
// 名次取「报名项那一份」(最小 id),值与升级前一致。
|
||||
func TestEventRowsKeepOneRowPerEntryWithMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntryWithMembers(t, db)
|
||||
|
||||
rows, err := loadEventRows(db, 1, 0, true, map[int64]jointTeam{})
|
||||
if err != nil {
|
||||
t.Fatalf("读成绩行: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("成绩表应只有一条报名那一行,得到 %d 行:%+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Rank != "1" {
|
||||
t.Errorf("名次应取报名项那一份(第 1 名),得到 %q", rows[0].Rank)
|
||||
}
|
||||
// 运动员列写队员的「姓名(号码布)」:号码布是唯一标识,导出侧要认得出是谁上了场
|
||||
// (写法与理由见 entry_members.go)。这条报名仍然只占这一行——挂队员不许把行展开。
|
||||
if rows[0].Participant != "王五(A001)、赵六(A002)" {
|
||||
t.Errorf("队员名单应写成「姓名(号码布)」,得到 %q", rows[0].Participant)
|
||||
}
|
||||
}
|
||||
|
||||
// seedEntryWithMembers 造一条「双打」团体报名:队员关联两条,channel_ranks 里三条名次
|
||||
// (报名项 + 两个队员的个人副本),与发布后的真实落库形状一致。
|
||||
func seedEntryWithMembers(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '羽毛球双打', 'team', 'auto', 'count', 'desc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names)
|
||||
VALUES (1, 1, 1, '["王五","赵六"]')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 0)`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 2, 1)`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 1, 1, '21', 21, '分', 'published')`,
|
||||
// 报名项那一份(先落库,id 最小)+ 两个队员的个人副本。
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:1', 1, 1, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:2', 1, 1, 2, 1, 10)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住导出侧的「团体报名的队员」(devdocs/20):报名名单的「姓名」列、分项成绩表 /
|
||||
// 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列,都要看得出这条报名里坐着谁。
|
||||
//
|
||||
// 写法是 `姓名(号码布)`(断言里的字面量就是导出件上那一格的字):号码布是运动员的唯一标识
|
||||
// (迁移 0014、devdocs/20 §二),光写名字遇到重名认不出人。多个队员用顿号连起来——与
|
||||
// member_names 原有的写法同形,读者不必为两种名单学两套读法。
|
||||
//
|
||||
// member_names **不因为挂了队员而消失**:它是文字名单,里面没被队员关联认走的那些名字
|
||||
// (人没录档案、临时借调的)照旧接在后面。两种情况都要能一眼看出谁上了场。
|
||||
//
|
||||
// 一条报名仍然只占一行(护栏在 entry_members_rows_test.go):队员只让这一行更长,
|
||||
// 不把报名展开成 N+1 行。
|
||||
|
||||
// seedDoublesMembers 造一个「双打」小项,三种报名形态各一条:
|
||||
// - 挂了队员的团体报名(文字名单里还多一个没录档案的名字);
|
||||
// - 只有文字名单的团体报名(升级前的老形状,用来钉住「没有队员关联时逐字不变」);
|
||||
// - seedEvent 的个人小项(100 米跑),个人报名的姓名一个字都不该变。
|
||||
//
|
||||
// channel_ranks 与流水的形状与发布后的真实落库一致:一条报名 1 + N 行名次(报名项自己那份
|
||||
// 排在 id 最小的位置 + 每个队员一份个人副本,subject_key 是 'e:<报名项>:a:<运动员>'),
|
||||
// 队员那两份流水恒 count_to_team=0(只进个人榜,不进单位总分)。
|
||||
func seedDoublesMembers(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
seedEvent(t, db) // T1/T2、张三 / 李四、100 米跑(两条已发布成绩与流水)
|
||||
stmts := []string{
|
||||
`INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
|
||||
// 组别 id 是 2(seedEvent 的 100 米跑已经占了 id 1):报名与名次都必须挂在它名下,
|
||||
// 否则导出按 (小项, 组别) 取成绩时一条都取不到。
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
|
||||
// 双打配的是个人通道(队长口径:上个人积分榜);通道的 count_to_team 由现场在通道里配,
|
||||
// 这里留默认 1——单位维度只算这条报名一次,与通道开关是两件事(见 domain.memberLedger)。
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (2, 2, 1, '["王五","赵六","临时借调的老周"]', 'registered')`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`,
|
||||
`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 4, 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (2, 2, 2, '["孙七","周八"]', 'registered')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (2, 3, 1, '21', 21, 'pts', 'published')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (2, 4, 2, '18', 18, 'pts', 'published')`,
|
||||
// 报名项那一份先落(id 最小 = 单位维度与成绩表读到的那一份),后面是队员的个人副本。
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3', 3, 3, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:3', 3, 3, 3, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:4', 3, 3, 4, 1, 1, 10)`,
|
||||
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, team_id, rank, points)
|
||||
VALUES (2, 2, 2, 'result', 'athlete', 'e:4', 4, 4, 2, 2, 8)`,
|
||||
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
|
||||
points, count_to_team)
|
||||
VALUES (1, 2, 3, 3, 'result', 3, 10, 1)`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
|
||||
source_id, points, count_to_team)
|
||||
VALUES (1, 3, 2, 3, 3, 'result', 3, 10, 0)`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type,
|
||||
source_id, points, count_to_team)
|
||||
VALUES (1, 4, 2, 3, 3, 'result', 3, 10, 0)`,
|
||||
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id,
|
||||
points, count_to_team)
|
||||
VALUES (2, 2, 4, 4, 'result', 4, 8, 1)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报名名单:「姓名」列写队员的「姓名(号码布)」,文字名单里没被认走的那个名字接在后面;
|
||||
// 没有队员关联的报名(老形状)一个字都不变。
|
||||
func TestEntriesWorkbookShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{EventID: 2}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读报名名单: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != 2 {
|
||||
t.Fatalf("双打应有 2 条报名,得到 %d 条:%+v", len(wb.Rows), wb.Rows)
|
||||
}
|
||||
if got := wb.Rows[0].Name; got != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("挂队员那行的姓名 = %q,期望 王五(A001)、赵六(A002)、临时借调的老周", got)
|
||||
}
|
||||
if got := wb.Rows[1].Name; got != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行的姓名 = %q,期望 孙七、周八(与升级前逐字一致)", got)
|
||||
}
|
||||
|
||||
// 导出件本身(落盘那份与响应体同源)也要是这一格的字。
|
||||
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
|
||||
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
|
||||
t.Fatalf("导出报名名单: %v", err)
|
||||
}
|
||||
f := openXLSX(t, dst)
|
||||
defer func() { _ = f.Close() }()
|
||||
rows, err := f.GetRows(entriesSheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单工作表: %v", err)
|
||||
}
|
||||
// 4 行版式 + 4 条报名(个人两条 + 双打两条);姓名是第 6 列。
|
||||
names := make([]string, 0, len(rows))
|
||||
for i := 4; i < len(rows); i++ {
|
||||
names = append(names, cell(rows, i, 5))
|
||||
}
|
||||
want := []string{"张三", "李四", "王五(A001)、赵六(A002)、临时借调的老周", "孙七、周八"}
|
||||
if !equalStrings(names, want) {
|
||||
t.Errorf("报名名单姓名列 = %v,期望 %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 分项成绩表与全部成绩单页的「运动员/队」列:队员带号码布写全,且**一条报名只占一行**。
|
||||
func TestEventWorkbookShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 2, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读分项成绩: %v", err)
|
||||
}
|
||||
if len(wb.Sheets) != 1 {
|
||||
t.Fatalf("双打只有 1 个组别,得到 %d 张表", len(wb.Sheets))
|
||||
}
|
||||
rows := wb.Sheets[0].Rows
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("成绩表应只有 2 条报名那两行,得到 %d 行:%+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Participant != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("第 1 名运动员/队 = %q,期望队员带号码布写全", rows[0].Participant)
|
||||
}
|
||||
if rows[0].Rank != "1" {
|
||||
t.Errorf("第 1 名名次 = %q,期望 1(取报名项那一份名次)", rows[0].Rank)
|
||||
}
|
||||
if rows[1].Participant != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", rows[1].Participant)
|
||||
}
|
||||
|
||||
flat, err := LoadFlatWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读全部成绩单页: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, sh := range flat.Sheets {
|
||||
for _, r := range sh.Rows {
|
||||
if r.Participant == "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("全部成绩单页里找不到队员带号码布的写法")
|
||||
}
|
||||
}
|
||||
|
||||
// 单位奖牌明细的「运动员/队」列:这块奖牌是哪支队拿的,队员照样写全。
|
||||
func TestMedalDetailShowsEntryMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读单位奖牌明细: %v", err)
|
||||
}
|
||||
var participants []string
|
||||
for _, sheet := range wb.Sheets {
|
||||
for _, r := range sheet.Rows {
|
||||
if r.EventName == "男子双打" {
|
||||
participants = append(participants, r.Participant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(participants) != 2 {
|
||||
t.Fatalf("两块奖牌各一行,得到 %d 行:%v", len(participants), participants)
|
||||
}
|
||||
if participants[0] != "王五(A001)、赵六(A002)、临时借调的老周" {
|
||||
t.Errorf("双打金牌那行 = %q,期望队员带号码布写全", participants[0])
|
||||
}
|
||||
if participants[1] != "孙七、周八" {
|
||||
t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", participants[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 个人得分明细:一条双打报名在个人维度上是**每人一行、每人一整份**(不是一行两个人、
|
||||
// 也不是一行半份)。这是主人定的口径(devdocs/20 §二):每个队员各拿一份完整的个人分,
|
||||
// 于是两个人都能上个人积分榜——导出件上就该是这样两行。
|
||||
//
|
||||
// 名单里没关联上人的名字(临时借调的)**不产生个人行**:他上不了个人榜,导出件也不该凭空给他记分。
|
||||
func TestAthleteDetailGivesEachMemberAFullCopy(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadAthleteDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读个人得分明细: %v", err)
|
||||
}
|
||||
byName := make(map[string][]AthleteDetailRow)
|
||||
for _, sheet := range wb.Sheets {
|
||||
for _, r := range sheet.Rows {
|
||||
byName[r.AthleteName] = append(byName[r.AthleteName], r)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"王五", "赵六"} {
|
||||
rows := byName[name]
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("%s 应有 1 行个人明细,得到 %d 行:%+v", name, len(rows), rows)
|
||||
}
|
||||
if rows[0].EventName != "男子双打" || rows[0].Points != 10 || rows[0].Rank != 1 {
|
||||
t.Errorf("%s 的个人明细 = %+v,期望 男子双打 / 第 1 名 / 10 分(一整份,不拆)", name, rows[0])
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"孙七", "周八", "临时借调的老周"} {
|
||||
if rows := byName[name]; len(rows) != 0 {
|
||||
t.Errorf("%s 没有个人关联 / 没有个人流水,不该出现在个人明细里,得到 %+v", name, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单位积分明细:队员的个人副本恒 count_to_team=0,所以这条报名在单位维度上**只算一次**——
|
||||
// 双打不会让单位的总分变成两份,单位明细里也只有报名项那一行。
|
||||
func TestTeamDetailCountsEntryOnceWithMembers(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedDoublesMembers(t, db)
|
||||
|
||||
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读单位积分明细: %v", err)
|
||||
}
|
||||
var doubles []TeamDetailRow
|
||||
for _, r := range wb.Rows {
|
||||
if r.EventName == "男子双打" {
|
||||
doubles = append(doubles, r)
|
||||
}
|
||||
}
|
||||
if len(doubles) != 1 {
|
||||
t.Fatalf("双打在单位明细里应只有 1 行(队员的副本不进单位总分),得到 %d 行:%+v", len(doubles), doubles)
|
||||
}
|
||||
if doubles[0].Points != 10 {
|
||||
t.Errorf("单位实得分 = %v,期望 10(只算报名项那一份,不是 30)", doubles[0].Points)
|
||||
}
|
||||
if doubles[0].AthleteName != "" {
|
||||
t.Errorf("单位明细的运动员列 = %q,期望留空(这一分记在单位头上)", doubles[0].AthleteName)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -200,7 +200,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
}
|
||||
excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false")
|
||||
hits := medalHits(ranked, excludeTeam)
|
||||
ctx, err := loadMedalContext(db)
|
||||
// 队员关联读一次给全部奖牌行复用(与联队份额同一次读取的理由)。
|
||||
members, err := loadEntryMembers(db)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
ctx, err := loadMedalContext(db, members)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
@@ -340,10 +345,12 @@ type medalContext struct {
|
||||
categoryID int64
|
||||
}
|
||||
|
||||
func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
// loadMedalContext 取每条成绩的展示字段;members 是报名 id → 队员(见 loadEntryMembers),
|
||||
// 用来把团体报名的队员写进「运动员 / 队」列。
|
||||
func loadMedalContext(db *sql.DB, members map[int64][]entryMember) (map[int64]medalContext, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''), en.id,
|
||||
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0),
|
||||
e.id, COALESCE(c.id, 0)
|
||||
FROM results r
|
||||
@@ -363,14 +370,15 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
resultID int64
|
||||
athleteName string
|
||||
memberJSON string
|
||||
entryID int64
|
||||
c medalContext
|
||||
)
|
||||
if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category,
|
||||
&athleteName, &memberJSON, &c.sportSort, &c.eventSort, &c.catSort,
|
||||
&athleteName, &memberJSON, &entryID, &c.sportSort, &c.eventSort, &c.catSort,
|
||||
&c.eventID, &c.categoryID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err)
|
||||
}
|
||||
c.participant = participantName(athleteName, memberJSON)
|
||||
c.participant = participantLabel(athleteName, memberJSON, members[entryID])
|
||||
out[resultID] = c
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -123,8 +123,13 @@ type entryDTO struct {
|
||||
Squad string `json:"squad"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
// Athletes 是这条报名的**队员**(团队 / 对抗项目的成员运动员),读侧始终下发数组
|
||||
// (空数组 = 只报单位,与升级前一致;不是 null,前端不必写两套渲染)。
|
||||
// 它是人员关联(athleteId),memberNames 只是显示用的文字名单——两者可以同时存在:
|
||||
// 老数据、以及「人没录档案只登记姓名」的报名就只有 memberNames。
|
||||
Athletes []entryAthleteDTO `json:"athletes"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
// Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。
|
||||
Heat *int64 `json:"heat"`
|
||||
Status string `json:"status"`
|
||||
@@ -139,6 +144,19 @@ type entryDTO struct {
|
||||
TeamShares []entryTeamShareDTO `json:"teamShares"`
|
||||
}
|
||||
|
||||
// entryAthleteDTO 是报名上的一名**队员**(devdocs/20):团队 / 对抗项目的成员运动员。
|
||||
//
|
||||
// - athleteId:人员档案的 id,**唯一身份**(认人时以号码布为准,姓名会重名);
|
||||
// - name / bib:展示字段(前端显示「姓名(号码布)」),写侧不需要带。
|
||||
//
|
||||
// 写侧(PUT /api/entries/{id}/athletes)只认 athleteId:把姓名 / 号码布认成人是导入与界面
|
||||
// 的事(导入的「队员名单」列可以写号码布),服务端不在这里二次猜测。
|
||||
type entryAthleteDTO struct {
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Bib *string `json:"bib,omitempty"`
|
||||
}
|
||||
|
||||
// entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。
|
||||
//
|
||||
// - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// 本文件是「团体报名的队员」(devdocs/20)的读写。
|
||||
//
|
||||
// 表达方式:**队员是报名上的人员关联**(entry_athletes),不是新的一条报名,也不改任何唯一键。
|
||||
// 与联队(entry_team_shares)对称而语义相反:那边是「一条报名 → 多个单位、按权重**拆**分」,
|
||||
// 这边是「一条报名 → 多个运动员、每人各拿**整份**分」(个人奖牌也是每人各一枚)。
|
||||
//
|
||||
// 写侧只认**运动员 id**:把姓名 / 号码布认成人是导入与界面的事(导入口径见 devdocs/20 §三:
|
||||
// 队员名单列可以填号码布),这里只负责把已经认准的身份留在报名上,不做二次猜测。
|
||||
|
||||
// maxEntryAthletes 是单条报名的队员上限。现场最大的团体项目(篮球、拔河)也就十几人,
|
||||
// 给到 30 已经远超需要;上限的作用是拦住「一整格都是 id」这类误填——每个队员都会产出
|
||||
// 一条名次与一条个人流水,放进来多少就落多少。
|
||||
const maxEntryAthletes = 30
|
||||
|
||||
// entryAthletesReady 判断队员表在不在。
|
||||
//
|
||||
// 与 entryTeamSharesReady 同一套:读路径要能读「还没跑到 0026 的库」(迁移在服务启动时补跑,
|
||||
// 但榜单可能在那之前被读一次)。表不在 = 库里没有任何队员关联 = 只报单位,正是老数据本来
|
||||
// 就该得到的结果,所以按「没有队员」处理是**精确**的,不是容错降级。
|
||||
func entryAthletesReady(ctx context.Context, q queryer) (bool, error) {
|
||||
var n int
|
||||
if err := q.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n != 0, nil
|
||||
}
|
||||
|
||||
// loadEntryAthletesQ 读一条 SQL 里给定范围内报名的队员,按 entry_id 归组,供引擎使用。
|
||||
// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。
|
||||
//
|
||||
// 排序按 (sort_order, athlete_id):写侧按请求给的顺序编号,展示顺序稳定;
|
||||
// 引擎不依赖顺序(每个队员各拿一整份分,与先后无关)。
|
||||
func loadEntryAthletesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.EntryAthlete, error) {
|
||||
ready, err := entryAthletesReady(ctx, q)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if !ready {
|
||||
return nil, nil
|
||||
}
|
||||
query := `SELECT ea.entry_id, ea.athlete_id, COALESCE(a.name, '')
|
||||
FROM entry_athletes ea
|
||||
JOIN athletes a ON a.id = ea.athlete_id`
|
||||
args := []any{}
|
||||
if eventID != 0 {
|
||||
query += ` JOIN entries e ON e.id = ea.entry_id WHERE e.event_id = ?`
|
||||
args = append(args, eventID)
|
||||
}
|
||||
query += ` ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`
|
||||
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.EntryAthlete)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
m domain.EntryAthlete
|
||||
)
|
||||
if err := rows.Scan(&entryID, &m.AthleteID, &m.Name); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[entryID] = append(out[entryID], m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadEntryAthletesDTOs 读某小项全部报名的队员读数形态:带上号码布(运动员的唯一标识),
|
||||
// 前端据此显示「姓名(号码布)」并让人对得上下一次导入写的是谁。
|
||||
func (s *server) loadEntryAthletesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryAthleteDTO, error) {
|
||||
ready, err := entryAthletesReady(ctx, q)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if !ready {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT ea.entry_id, ea.athlete_id, a.name, a.bib
|
||||
FROM entry_athletes ea
|
||||
JOIN entries e ON e.id = ea.entry_id
|
||||
JOIN athletes a ON a.id = ea.athlete_id
|
||||
WHERE e.event_id = ?
|
||||
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]entryAthleteDTO)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
item entryAthleteDTO
|
||||
bib sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&entryID, &item.AthleteID, &item.Name, &bib); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
item.Bib = strPtr(bib)
|
||||
out[entryID] = append(out[entryID], item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// entryAthletesOf 把一条报名的队员补齐成「始终有值」的数组:没有队员时是空数组而不是 null,
|
||||
// 前端不必为「有没有队员」写两套渲染(与 memberNames 那种可空文本不同,队员是一组关联)。
|
||||
func entryAthletesOf(configured []entryAthleteDTO) []entryAthleteDTO {
|
||||
if len(configured) == 0 {
|
||||
return []entryAthleteDTO{}
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
// replaceEntryAthletes 按给定顺序重写一条报名的队员(先删后插,幂等)。
|
||||
//
|
||||
// 为什么每次都重写、而不是「有队员才写」:报名可以被改成另一个样子(换人、减人、清空)。
|
||||
// 只增不减会留下旧队员,那条报名以后一直按早就改掉的人头发个人分。
|
||||
// 传空数组是**合法值**:清空队员 = 只报单位(与升级前一致)。
|
||||
func replaceEntryAthletes(ctx context.Context, q queryer, entryID int64, athletes []entryAthleteDTO) error {
|
||||
if _, err := q.ExecContext(ctx, `DELETE FROM entry_athletes WHERE entry_id = ?`, entryID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
for i, a := range athletes {
|
||||
if _, err := q.ExecContext(ctx,
|
||||
`INSERT INTO entry_athletes (entry_id, athlete_id, sort_order) VALUES (?, ?, ?)`,
|
||||
entryID, a.AthleteID, i); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEntryAthletes 校验一次队员改动,返回给人看的 400 错误。
|
||||
//
|
||||
// 规则(每一条都对着一种会算错的写法):
|
||||
// - athleteId 必填且必须存在:队员身份只认运动员档案,姓名与号码布只是显示;
|
||||
// - 同一个人只能出现一次:重复会让同一条成绩给同一个人记两份分、两枚牌;
|
||||
// - 人数不超过 maxEntryAthletes:拦住「一整格都是 id」这类误填。
|
||||
func (s *server) validateEntryAthletes(ctx context.Context, athletes []entryAthleteDTO) error {
|
||||
if len(athletes) > maxEntryAthletes {
|
||||
return badRequest(fmt.Sprintf("一条报名的队员最多 %d 人:队伍人数不可能这么多,请检查是不是误填", maxEntryAthletes), nil)
|
||||
}
|
||||
ids := make([]int64, 0, len(athletes))
|
||||
seen := make(map[int64]bool, len(athletes))
|
||||
for i, a := range athletes {
|
||||
if a.AthleteID == 0 {
|
||||
return badRequest("athletes[].athleteId 必填:队员身份只认运动员档案里的那个人(不是姓名文字)", i)
|
||||
}
|
||||
if seen[a.AthleteID] {
|
||||
return badRequest(fmt.Sprintf("athletes 里运动员 %d 出现了两次:同一条报名里一个人只能占一个位置", a.AthleteID), i)
|
||||
}
|
||||
seen[a.AthleteID] = true
|
||||
ids = append(ids, a.AthleteID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
exists := make(map[int64]bool, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
exists[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !exists[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return badRequest("athleteId 不存在(人员已被彻底删除时也会查不到)", missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateEntryAthletes 整组替换一条报名的队员并重算它所在的小项组别。
|
||||
//
|
||||
// 必须重算(与改份额那条接口同口径,而与改队名那条不同):队员决定「哪些人拿到个人分与
|
||||
// 个人奖牌」,改完不重算就是「界面显示换人了、榜单还是旧人」。队名 / 批次那类纯展示字段
|
||||
// 才走「不重算」那条路。
|
||||
func (s *server) updateEntryAthletes(ctx context.Context, entryID int64, athletes []entryAthleteDTO, actorID int64) (entryDTO, error) {
|
||||
entry, err := s.getEntry(ctx, entryID)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
// 带运动员的报名自己就是那个人的身份((组别, 单位, 运动员) 唯一),再挂队员等于说
|
||||
// 「这条报名既是一个人又是一支队」,两条口径会同时往个人榜上写。直接拒掉并说清原因。
|
||||
if entry.AthleteID != nil {
|
||||
return entryDTO{}, badRequest("这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名", nil)
|
||||
}
|
||||
if err := s.validateEntryAthletes(ctx, athletes); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := replaceEntryAthletes(ctx, tx, entryID, athletes); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
score, err := recomputeLedgerTx(ctx, tx, entry.EventID, entry.CategoryID, actorID, "报名队员变更后重算")
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, entry.EventID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
return s.getEntry(ctx, entryID)
|
||||
}
|
||||
|
||||
// updateEntryAthletesHandler 是 PUT /api/entries/{id}/athletes:
|
||||
// 整组替换一条团体报名的队员(空数组 = 清空,只报单位),成功后返回这条报名的完整读数。
|
||||
func (s *server) updateEntryAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in entryAthletesPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
before, err := s.getEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateEntryAthletes(r.Context(), id, in.Athletes, middleware.User(r.Context()).ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entry.athletes.update", "entry", id,
|
||||
map[string]any{"athletes": before.Athletes}, map[string]any{"athletes": updated.Athletes})
|
||||
return updated, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路:
|
||||
// - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致);
|
||||
// - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混);
|
||||
// - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次,
|
||||
// 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。
|
||||
|
||||
// seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道
|
||||
// (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。
|
||||
// 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。
|
||||
func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (eventID, categoryID int64) {
|
||||
t.Helper()
|
||||
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status)
|
||||
VALUES (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID)
|
||||
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order,
|
||||
count_to_team, count_to_team_medals)
|
||||
VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`,
|
||||
eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals)
|
||||
return eventID, categoryID
|
||||
}
|
||||
|
||||
// doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。
|
||||
func doublesImportRow(eventCode, category, teamCode string) string {
|
||||
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",")
|
||||
}
|
||||
|
||||
// putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。
|
||||
func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) {
|
||||
t.Helper()
|
||||
items := make([]map[string]any, 0, len(athleteIDs))
|
||||
for _, id := range athleteIDs {
|
||||
items = append(items, map[string]any{"athleteId": id})
|
||||
}
|
||||
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
|
||||
map[string]any{"athletes": items}, true)
|
||||
}
|
||||
|
||||
// entryAthleteRows 把某小项的队员关联表导成稳定文本。
|
||||
func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string {
|
||||
t.Helper()
|
||||
return tableDump(t, env, fmt.Sprintf(
|
||||
`SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
|
||||
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
|
||||
JOIN entries e ON e.id = ea.entry_id
|
||||
WHERE e.event_id = %d
|
||||
ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID))
|
||||
}
|
||||
|
||||
// athleteBoard 是 GET /api/standings/athletes 的原始读数。
|
||||
func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
return decodeData[[]map[string]any](t, resp)
|
||||
}
|
||||
|
||||
// athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。
|
||||
func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any {
|
||||
t.Helper()
|
||||
for _, row := range athleteBoard(t, env, client) {
|
||||
if row["athleteId"] == float64(athleteID) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client))
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertAthlete 断言个人榜上某人的总分与金银铜。
|
||||
func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) {
|
||||
t.Helper()
|
||||
row := athleteRow(t, env, client, athleteID)
|
||||
if got := row["totalPoints"].(float64); got != points {
|
||||
t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, got, points, row)
|
||||
}
|
||||
for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} {
|
||||
if got := row[name].(float64); got != float64(want) {
|
||||
t.Errorf("运动员 %d 的 %s = %v,期望 %d(整行:%+v)", athleteID, name, got, want, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌;
|
||||
// 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。
|
||||
func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
|
||||
// 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。
|
||||
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
|
||||
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
|
||||
|
||||
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
|
||||
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
// 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
|
||||
entryID, ids.athleteA, entryID, partner)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
|
||||
publishResults(t, env, client, eventID, "doubles-members",
|
||||
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
|
||||
|
||||
// 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。
|
||||
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
|
||||
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
|
||||
|
||||
// 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。
|
||||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||||
}
|
||||
|
||||
// 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。
|
||||
func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
|
||||
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
|
||||
|
||||
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
|
||||
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
|
||||
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
publishResults(t, env, client, eventID, "doubles-flags-off",
|
||||
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
|
||||
|
||||
// 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。
|
||||
assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0)
|
||||
// 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。
|
||||
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
|
||||
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
|
||||
}
|
||||
|
||||
// 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。
|
||||
func TestEntryReadsIncludeAthletes(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
|
||||
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
|
||||
env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA)
|
||||
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
|
||||
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
|
||||
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
board := decodeData[entryBoard](t, resp)
|
||||
if len(board.Entries) != 1 {
|
||||
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
|
||||
}
|
||||
athletes, _ := board.Entries[0]["athletes"].([]any)
|
||||
if len(athletes) != 2 {
|
||||
t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"])
|
||||
}
|
||||
first, _ := athletes[0].(map[string]any)
|
||||
if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" {
|
||||
t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 队员改动要能拦住会算错的写法,并且一律不落库。
|
||||
func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK {
|
||||
t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK {
|
||||
t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
baseline := entryAthleteRows(t, env, eventID)
|
||||
|
||||
// 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。
|
||||
personalEntryID := ids.entryA
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID),
|
||||
map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"},
|
||||
{"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID),
|
||||
map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"},
|
||||
{"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID),
|
||||
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"},
|
||||
{"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID),
|
||||
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"},
|
||||
{"报名不存在", "/api/entries/999999/athletes",
|
||||
map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true)
|
||||
wantStatus := http.StatusBadRequest
|
||||
if tc.name == "报名不存在" {
|
||||
wantStatus = http.StatusNotFound
|
||||
}
|
||||
if status != wantStatus {
|
||||
t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp)
|
||||
}
|
||||
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
|
||||
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
|
||||
}
|
||||
if got := entryAthleteRows(t, env, eventID); got != baseline {
|
||||
t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 空数组是合法值:清空队员 = 只报单位(与升级前一致)。
|
||||
if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID),
|
||||
map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if got := entryAthleteRows(t, env, eventID); got != "" {
|
||||
t.Fatalf("清空后队员表应为空,得到 %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件是「老库升级到 0026」这一跳在接口层的验证:拿一个升级前(0025)的库,塞进老数据,
|
||||
// 走真实升级路径(store.MigrateEvent)后从报名一路打到榜单。
|
||||
//
|
||||
// 三条底线:
|
||||
// - 回填:老报名 member_names 里认得出的人被挂上(号码布优先、姓名+单位其次、重名不猜);
|
||||
// - 队员拿个人分与个人奖牌(这是本单要的效果);
|
||||
// - **单位维度不变**:单位总分与单位奖牌仍旧只算这条报名一次。
|
||||
|
||||
// seedLegacyDoublesEvent 在升级前的库里造两条「双打」团体报名(A、B 两个单位各一条):
|
||||
// 团体项目 + 个人通道(现场的双打就是这么配的),队员名单只有文字(老数据的唯一形态)。
|
||||
// 两个 memberNames 参数分别是 B 单位那条与 A 单位那条的队员名单(NULL 表示没填名单)。
|
||||
func seedLegacyDoublesEvent(t *testing.T, db *sql.DB, memberNamesB, memberNamesA string) {
|
||||
t.Helper()
|
||||
exec := func(query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
t.Fatalf("造老库数据失败: %v\n%s", err, query)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
|
||||
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
|
||||
// 号码布是运动员的唯一标识:下面按号码布/姓名两种写法各认一个。
|
||||
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('张伟', '男', 1, 'A001', 1)`)
|
||||
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('王五', '男', 1, 'A002', 1)`)
|
||||
exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('李强', '男', 2, 'B001', 1)`)
|
||||
exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
|
||||
exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status)
|
||||
VALUES (1, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 1, 'ongoing')`)
|
||||
exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`)
|
||||
exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`)
|
||||
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
|
||||
VALUES (1, 1, 2, NULL, ?, 'registered')`, memberNamesB)
|
||||
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status, squad)
|
||||
VALUES (1, 1, 1, NULL, ?, 'registered', '')`, memberNamesA)
|
||||
}
|
||||
|
||||
// newLegacyDoublesEnv 造一个「升级前的老库 → 升级 → 起服务」的完整现场。
|
||||
func newLegacyDoublesEnv(t *testing.T, memberNamesB, memberNamesA string) *testEnv {
|
||||
t.Helper()
|
||||
return newTestEnvPrepared(t, func(ev *sql.DB) {
|
||||
applyEventMigrationsBefore(t, ev, "0026") // 回到「升级前」:库结构到 0025 为止
|
||||
seedLegacyDoublesEvent(t, ev, memberNamesB, memberNamesA)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// 老库升级:名单里认得出的人被回填成队员,于是双打两个人各拿一份个人分、各一枚个人奖牌;
|
||||
// 而单位维度与升级前逐字节一致(单位只算一次分、只拿一枚牌)。
|
||||
func TestLegacyDoublesUpgradeBackfillsMembersAndScoresIndividually(t *testing.T) {
|
||||
// 老报名写的是一半号码布(A001 = 张伟)、一半姓名(王五)——两种写法都该认出来。
|
||||
// 另一条报名(B 单位)写的是姓名「李强」,也在升级时认出来(下面 dump 里是第 1 行)。
|
||||
env := newLegacyDoublesEnv(t, `["李强"]`, `["A001","王五"]`)
|
||||
|
||||
// 升级那一刻做的回填:两个人都挂上了,顺序按 member_names 的书写顺序。
|
||||
dump := tableDump(t, env, `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order
|
||||
FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id
|
||||
ORDER BY ea.entry_id, ea.sort_order`)
|
||||
want := "entry_id=1 athlete_id=3 name=李强 sort_order=0 \n" +
|
||||
"entry_id=2 athlete_id=1 name=张伟 sort_order=0 \n" +
|
||||
"entry_id=2 athlete_id=2 name=王五 sort_order=1 \n"
|
||||
if dump != want {
|
||||
t.Fatalf("升级回填不对:\n得到 %s期望 %s", dump, want)
|
||||
}
|
||||
|
||||
client, _ := env.client()
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
entryA := entryIDOf(t, env, 1, 1) // A 单位的双打
|
||||
entryB := entryIDOf(t, env, 2, 1) // B 单位的单人报名
|
||||
publishResults(t, env, client, 1, "legacy-doubles-upgrade",
|
||||
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
|
||||
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
|
||||
)
|
||||
|
||||
// 个人榜:双打的两个人各一份满分(第 1 名 10 分)+ 各一枚金牌。
|
||||
assertAthlete(t, env, client, 1, 10, 1, 0, 0) // 张伟
|
||||
assertAthlete(t, env, client, 2, 10, 1, 0, 0) // 王五
|
||||
assertAthlete(t, env, client, 3, 8, 0, 1, 0) // 李强(对照)
|
||||
|
||||
// 单位维度:分配了队员也仍然只算这条报名一次(不是 20 分、也不是两枚金牌)。
|
||||
assertStanding(t, env, client, 1, 10, 1, 0, 0)
|
||||
assertStanding(t, env, client, 2, 8, 0, 1, 0)
|
||||
|
||||
// 流水层面把「不重复计算」摊开看:单位那一份只有一条(athlete_id 为空、count_to_team=1),
|
||||
// 队员的两条是「只记个人」(count_to_team=0),所以单位总分与单位奖牌都看不到它们。
|
||||
ledger := tableDump(t, env, `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0 ORDER BY team_id, athlete_id`)
|
||||
wantLedger := "team_id=1 athlete_id=0 points=10 count_to_team=1 \n" +
|
||||
"team_id=1 athlete_id=1 points=10 count_to_team=0 \n" +
|
||||
"team_id=1 athlete_id=2 points=10 count_to_team=0 \n" +
|
||||
"team_id=2 athlete_id=0 points=8 count_to_team=1 \n" +
|
||||
"team_id=2 athlete_id=3 points=8 count_to_team=0 \n"
|
||||
if ledger != wantLedger {
|
||||
t.Fatalf("流水不对:\n得到 %s期望 %s", ledger, wantLedger)
|
||||
}
|
||||
}
|
||||
|
||||
// 名单认不出人的老报名(重名、查无此人)升级后照旧:上不了个人榜,单位维度一个字不变。
|
||||
func TestLegacyDoublesUpgradeWithoutMatchKeepsBehavior(t *testing.T) {
|
||||
// 两条报名写的名字都查不到人(重名 / 查无此人):一条关联都不许回填。
|
||||
env := newLegacyDoublesEnv(t, `NULL`, `["查无此人","李强","李强"]`)
|
||||
|
||||
if got := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes`); got != 0 {
|
||||
t.Fatalf("认不出人时不该回填任何关联(B 单位没填名单、A 单位查无此人),得到 %d 条", got)
|
||||
}
|
||||
|
||||
client, _ := env.client()
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
entryA := entryIDOf(t, env, 1, 1)
|
||||
entryB := entryIDOf(t, env, 2, 1)
|
||||
publishResults(t, env, client, 1, "legacy-doubles-nomatch",
|
||||
map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"},
|
||||
map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"},
|
||||
)
|
||||
|
||||
// 单位维度与升级前一样(A 第 1 名 10 分 1 金,B 第 2 名 8 分 1 银)。
|
||||
assertStanding(t, env, client, 1, 10, 1, 0, 0)
|
||||
assertStanding(t, env, client, 2, 8, 0, 1, 0)
|
||||
// 个人榜上一条都没有:队员认不出来就上不了个人榜(这正是回填要解决的事)。
|
||||
if rows := athleteBoard(t, env, client); len(rows) != 0 {
|
||||
t.Fatalf("认不出人时个人榜应为空,得到 %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 双打 + 双通道(个人成绩通道 + 团体手工通道):连发两次必须逐字节一致。
|
||||
//
|
||||
// results.rank 在团体报名项上是「单位名次」的录入位(团体手工通道的兜底来源)。
|
||||
// 队员的个人副本不许往这一列写回自动名次——否则第二次发布会把自动名次当成团体名次再发一遍分,
|
||||
// 就是工单 A-1 那个坑,只不过这次是队员副本踩进去的。
|
||||
func TestDoublesMembersDoNotClobberManualTeamRank(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, categoryID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status)
|
||||
VALUES (?, 'DBL2', '羽毛球双打(双通道)', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport), int64(0)
|
||||
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6"))
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||||
|
||||
partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
|
||||
VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA)
|
||||
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != 200 {
|
||||
t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
// 团体名次按单位录(第 3 名 = 8 分),双打自己的成绩值给出个人通道的第 1 名(10 分)。
|
||||
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 3},
|
||||
}); status != 200 {
|
||||
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
publishResults(t, env, client, eventID, "doubles-dual-p1",
|
||||
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
|
||||
firstLedger := tableDump(t, env, fmt.Sprintf(
|
||||
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID))
|
||||
// 名次不许被队员副本写回:团体报名项的 results.rank 仍是「没写」(名次按单位录入在
|
||||
// channel_manual_ranks 里,这一列是它的兜底录入位,被自动名次覆盖就会在第二次发布时
|
||||
// 被当成团体名次再发一遍分)。
|
||||
if got := resultRanksDump(t, env, eventID); got != fmt.Sprintf("entry_id=%d rank=0 value=90 \n", entryID) {
|
||||
t.Fatalf("团体名次(results.rank 录入位)被自动名次改写了:\n%s", got)
|
||||
}
|
||||
// 单位总分 = 个人通道第 1 名的 10 分(报名项那一份)+ 团体手工通道第 3 名的 8 分,只算一次。
|
||||
// 奖牌 1 金 1 铜是双通道本来就有的口径(两条通道各自发自己那份名次的牌,与队员无关)。
|
||||
assertStanding(t, env, client, ids.teamA, 18, 1, 0, 1)
|
||||
// 队员各拿个人通道那一份 10 分与个人金牌。
|
||||
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
|
||||
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
|
||||
|
||||
publishEvent(t, env, client, eventID, "doubles-dual-p2")
|
||||
if got := tableDump(t, env, fmt.Sprintf(
|
||||
`SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID)); got != firstLedger {
|
||||
t.Fatalf("第二次发布改了流水(同一份数据发两次结果不同):\n第一次\n%s第二次\n%s", firstLedger, got)
|
||||
}
|
||||
}
|
||||
|
||||
// entryIDOf 取某 (单位, 小项) 的纯团体报名 id。
|
||||
func entryIDOf(t *testing.T, env *testEnv, teamID, eventID int64) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT id FROM entries WHERE team_id = ? AND event_id = ? AND athlete_id IS NULL`, teamID, eventID).Scan(&id); err != nil {
|
||||
t.Fatalf("读报名项: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -393,6 +393,11 @@ func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error)
|
||||
return entryDTO{}, err
|
||||
}
|
||||
e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName)
|
||||
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, e.EventID)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
e.Athletes = entryAthletesOf(athletes[e.ID])
|
||||
return e, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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 边界与表头还占少量字节,
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「导入侧把队员名单认成人」(devdocs/20 §三):
|
||||
// - 「队员名单」列除了是显示用的文字,还能写**号码布**:认得上就建立 entry_athletes 关联,
|
||||
// 认不上照旧只当文字(现状不变,不报错);
|
||||
// - 号码布是运动员的唯一标识:一格里的内容同时能当成号码布与姓名时,以号码布为准;
|
||||
// - 新增与更新两条路都要同步队员关联:认出了人的那一格整组重写(与 squad / 份额同一口径),
|
||||
// 一个号码布都认不出的格子只当文字、不动已有队员(老表与界面两条路的并存,见各用例)。
|
||||
|
||||
// memberImportRow 拼一行团体报名(不填姓名 = 团体行),列序与 importHeader 一致。
|
||||
func memberImportRow(eventCode, category, teamCode, members string) string {
|
||||
return strings.Join([]string{eventCode, category, teamCode, "",
|
||||
"", "", "", "", "", "registered", members}, ",")
|
||||
}
|
||||
|
||||
// seedBib 给库里已有的运动员写一个号码布(种子里的运动员默认没有号码布)。
|
||||
func seedBib(t *testing.T, env *testEnv, athleteID int64, bib string) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE athletes SET bib = ? WHERE id = ?`, bib, athleteID)
|
||||
}
|
||||
|
||||
// seedMember 造一个带号码布的运动员(导入要认的就是号码布)。
|
||||
func seedMember(t *testing.T, env *testEnv, name string, teamID int64, bib string) int64 {
|
||||
t.Helper()
|
||||
return env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active)
|
||||
VALUES (?, '男', ?, ?, 1)`, name, teamID, bib)
|
||||
}
|
||||
|
||||
// entryMemberNames 读一条报名的文字名单原文(member_names 在库里是 JSON 数组文本)。
|
||||
func entryMemberNames(t *testing.T, env *testEnv, entryID int64) string {
|
||||
t.Helper()
|
||||
var raw string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(member_names, '') FROM entries WHERE id = ?`, entryID).Scan(&raw); err != nil {
|
||||
t.Fatalf("读 member_names: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// noticesText 把 notices 拼成一段文本,便于断言「说了什么」。
|
||||
func noticesText(result entryImportResultBody) string {
|
||||
return strings.Join(result.Notices, "\n")
|
||||
}
|
||||
|
||||
// 号码布认得人:一格里的号码布与该行的文字名单并存 —— 关联建立、文字原样保留,
|
||||
// 且这些队员当场就能上个人积分榜(导入路径的完整目的)。
|
||||
func TestImportMemberNamesLinksByBibAndKeepsText(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
partner := seedMember(t, env, "王五", ids.teamA, "A002")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "A002/A001/李四"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 || len(result.Errors) != 0 {
|
||||
t.Fatalf("应新建 1 条报名且无报错,得到 %+v", result)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
// 顺序跟这一格的书写顺序走(号码布写在前的排在前)。
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \nentry_id=%d athlete_id=%d name=张伟 sort_order=1 \n",
|
||||
entryID, partner, entryID, ids.athleteA)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("队员关联应按号码布认出王五与张伟:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
// 认不出的「李四」不报错,文字名单三个都原样留着。
|
||||
if got, want := entryMemberNames(t, env, entryID), `["A002","A001","李四"]`; got != want {
|
||||
t.Fatalf("member_names 应原样保留,得到 %s 期望 %s", got, want)
|
||||
}
|
||||
if got := noticesText(result); !strings.Contains(got, "关联了 2 名队员") {
|
||||
t.Fatalf("认了人要说得出来(现场要知道谁上了个人榜),得到 notices:%s", got)
|
||||
}
|
||||
|
||||
// 认出来的人当场参与算分:每人一份满分、各一枚个人奖牌;单位仍然只算这一条报名一次。
|
||||
publishResults(t, env, client, eventID, "import-members",
|
||||
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
|
||||
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
|
||||
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
|
||||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||||
}
|
||||
|
||||
// 一格里的内容同时能当成号码布与姓名时,以号码布为准(主人 2026-09-14 定的口径)。
|
||||
func TestImportMemberNamesPrefersBibOverName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
byBib := seedMember(t, env, "赵六", ids.teamA, "1001")
|
||||
byName := seedMember(t, env, "1001", ids.teamA, "")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "1001"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=赵六 sort_order=0 \n", entryID, byBib)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("1001 既是号码布又是另一个人的姓名时应关联号码布那个人:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
var linked int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entry_athletes WHERE athlete_id = ?`, byName).Scan(&linked); err != nil {
|
||||
t.Fatalf("查关联: %v", err)
|
||||
}
|
||||
if linked != 0 {
|
||||
t.Fatalf("不该关联同名的另一个人(%d)", byName)
|
||||
}
|
||||
}
|
||||
|
||||
// 只写姓名(认不出号码布)时现状不变:只当文字,不报错、不关联。
|
||||
func TestImportMemberNamesUnknownStaysTextOnly(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
// 库里有个同单位同名的张伟,但号码布列认的是号码布:姓名不做匹配(同名会认错人)。
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "张伟/李四"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 || len(result.Errors) != 0 {
|
||||
t.Fatalf("只写姓名应照常导入、不报错,得到 %+v", result)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
if got := entryAthleteRows(t, env, eventID); got != "" {
|
||||
t.Fatalf("只写姓名不该建立任何关联,得到 %s", got)
|
||||
}
|
||||
if got, want := entryMemberNames(t, env, entryID), `["张伟","李四"]`; got != want {
|
||||
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
|
||||
}
|
||||
if got := noticesText(result); strings.Contains(got, "关联") {
|
||||
t.Fatalf("没认到人时不该说关联,得到 notices:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布认到的人属于别的单位:照号码布认(号码布是唯一标识,与迁移回填同一口径),
|
||||
// 但要说清楚是谁、在哪两个单位之间 —— 这是「填错号码布」最容易发生的写法。
|
||||
func TestImportMemberNamesReportsCrossTeamMember(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athB, "B001")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "B001"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=李强 sort_order=0 \n", entryID, ids.athB)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("号码布认到的人照关联(不看单位):\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
notice := noticesText(result)
|
||||
for _, part := range []string{"第 2 行", "B001", "李强", "单位 B", "单位 A"} {
|
||||
if !strings.Contains(notice, part) {
|
||||
t.Fatalf("跨单位关联要说清 %q,得到 notices:%s", part, notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 后补队员:先导入一条只报单位的团体报名、成绩已经发布上榜;之后同一行补上号码布重传,
|
||||
// 队员要**当场参与算分**(个人榜上出现他们),不必再发布一次成绩。
|
||||
// 这与「改了谁算数就必须重算这一组」是同一条口径(updateEntryAthletes 也是这样)。
|
||||
func TestImportMemberNamesRecomputesAfterUpdate(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
partner := seedMember(t, env, "王五", ids.teamA, "A002")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", ""))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
publishResults(t, env, client, eventID, "members-later",
|
||||
map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"})
|
||||
// 前置条件:还没有队员,个人榜上不该有这两个人。
|
||||
if got := entryAthleteRows(t, env, eventID); got != "" {
|
||||
t.Fatalf("前置条件:首次导入没有队员,得到 %s", got)
|
||||
}
|
||||
|
||||
status, resp = importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "A001/A002"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("补队员重传应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
|
||||
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
|
||||
}
|
||||
assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0)
|
||||
assertAthlete(t, env, client, partner, 10, 1, 0, 0)
|
||||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||||
}
|
||||
|
||||
// 重传同一行(走更新那条路)也要整组重写队员:换人跟得上,不留上一次的旧队员;
|
||||
// 而认不出号码布的格子(整列留空 / 只写姓名)只当文字,不动已有的队员关联。
|
||||
func TestImportMemberNamesRewrittenOnUpdate(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
partner := seedMember(t, env, "王五", ids.teamA, "A002")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "A001"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
status, resp = importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "A002"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 {
|
||||
t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got)
|
||||
}
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \n", entryID, partner)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("换人后队员应整组重写:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
|
||||
// 走界面把队员改成两个人(导入与界面是两条并用的路)。
|
||||
if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK {
|
||||
t.Fatalf("界面设置队员应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
both := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
|
||||
entryID, ids.athleteA, entryID, partner)
|
||||
|
||||
// 这一格一个号码布都认不出来(整列留空 / 只写姓名)时**只当文字**:已有的队员关联一行都不动。
|
||||
// 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移回填出来的队员、界面上勾好的人,
|
||||
// 都不该被一份没有号码布的名单悄悄清掉(那等于让人无声无息地掉出个人积分榜)。
|
||||
for _, cell := range []string{"", "张伟/王五"} {
|
||||
status, resp = importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", cell))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("重传(队员列 %q)应 200,得到 %d %+v", cell, status, resp.Error)
|
||||
}
|
||||
if got := entryAthleteRows(t, env, eventID); got != both {
|
||||
t.Fatalf("队员列 %q 认不出号码布时不该改动已有队员:\n得到 %s期望 %s", cell, got, both)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 联队(单位编码写 A+B)的队员本来就该来自任何一个成员单位:认人都认,
|
||||
// 但这类「跨单位」不是填错号码布,不该报假警报去让人改一个正确的号码布。
|
||||
func TestImportMemberNamesAcceptsJointTeamMembers(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
seedBib(t, env, ids.athB, "B001")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A+B", "A001/B001"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=李强 sort_order=1 \n",
|
||||
entryID, ids.athleteA, entryID, ids.athB)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("联队两个成员单位的队员都该关联:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
if got := noticesText(result); strings.Contains(got, "请核对号码布") {
|
||||
t.Fatalf("联队成员单位的人不该被当成填错号码布,得到 notices:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一格里把同一个号码布写了两次:只关联一次(同一条报名里一个人只能占一个位置),并说一声。
|
||||
func TestImportMemberNamesDedupesRepeatedBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", "A001/A001"))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \n", entryID, ids.athleteA)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("同一个号码布写两次只该关联一次:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
if got := noticesText(result); !strings.Contains(got, "A001") || !strings.Contains(got, "两次") {
|
||||
t.Fatalf("号码布写了两次要说一声,得到 notices:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 带运动员姓名的那一行按个人报名处理:队员只属于「只报单位」的团体报名,
|
||||
// 这一格只作文字保留,并且要说出来(与队伍列同一条口径,不静默丢)。
|
||||
func TestImportMemberNamesOnIndividualRowIsTextOnly(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
|
||||
row := strings.Join([]string{"DBL", "男子", "A", "张三", "110101199001011234", "男", "26", "A101",
|
||||
"", "registered", "A001"}, ",")
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader, row)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 || len(result.Errors) != 0 {
|
||||
t.Fatalf("个人行应照常导入,得到 %+v", result)
|
||||
}
|
||||
var entryID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND athlete_id IS NOT NULL`, eventID).Scan(&entryID); err != nil {
|
||||
t.Fatalf("读个人报名: %v", err)
|
||||
}
|
||||
if got := entryAthleteRows(t, env, eventID); got != "" {
|
||||
t.Fatalf("个人报名不该挂队员,得到 %s", got)
|
||||
}
|
||||
if got, want := entryMemberNames(t, env, entryID), `["A001"]`; got != want {
|
||||
t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want)
|
||||
}
|
||||
if got := noticesText(result); !strings.Contains(got, "个人报名") {
|
||||
t.Fatalf("个人行里的队员名单没有关联,要说清楚,得到 notices:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 分工作表模板(界面上的「补报名 / 团体报名 CSV」走的就是导入)同样认号码布。
|
||||
func TestImportSheetTemplateLinksMembersByBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
seedBib(t, env, ids.athleteA, "A001")
|
||||
partner := seedMember(t, env, "王五", ids.teamA, "A002")
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "羽毛球双打@男子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "", "A", "", "", "", "", "", "", "", "registered", "A001/A002", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "团体报名.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("分工作表模板应导入成功,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
var eventID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM events WHERE code = 'DBL'`).Scan(&eventID); err != nil {
|
||||
t.Fatalf("读小项: %v", err)
|
||||
}
|
||||
entryID := firstEntryID(t, env, eventID)
|
||||
want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n",
|
||||
entryID, ids.athleteA, entryID, partner)
|
||||
if got := entryAthleteRows(t, env, eventID); got != want {
|
||||
t.Fatalf("分工作表导入也该按号码布关联队员:\n得到 %s期望 %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 一格号码布超过一条报名的队员上限(maxEntryAthletes = 30):整行拦下并说清,
|
||||
// 不静默写成一条界面改不动的报名(与联队「最多 5 个单位」同一条口径)。
|
||||
func TestImportMemberNamesRejectsTooManyMembers(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0)
|
||||
|
||||
tokens := make([]string, 0, 31)
|
||||
for i := 1; i <= 31; i++ {
|
||||
bib := fmt.Sprintf("M%03d", i)
|
||||
seedMember(t, env, fmt.Sprintf("队员%d", i), ids.teamA, bib)
|
||||
tokens = append(tokens, bib)
|
||||
}
|
||||
status, resp := importCSVWithHeader(t, env, client, importHeader,
|
||||
memberImportRow("DBL", "男子", "A", strings.Join(tokens, "/")))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("行级拦截应回 200 + 行级报错,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Skipped != 1 || result.Created != 0 {
|
||||
t.Fatalf("超过上限的行应被拦下,得到 %+v", result)
|
||||
}
|
||||
if got := strings.Join(result.Errors, "|"); !strings.Contains(got, "30") {
|
||||
t.Fatalf("报错要说清上限是多少,得到 %s", got)
|
||||
}
|
||||
if got := entryAthleteRows(t, env, eventID); got != "" {
|
||||
t.Fatalf("被拦下的行不该落队员,得到 %s", got)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,13 @@ type entrySquadPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// entryAthletesPayload 是 PUT /api/entries/{id}/athletes 的请求体:整组替换一条报名的队员。
|
||||
// 空数组是**合法值**(清空队员 = 只报单位,与升级前一致);只认 athleteId。
|
||||
type entryAthletesPayload struct {
|
||||
Athletes []entryAthleteDTO `json:"athletes"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// entryBatchPayload 是报名项批量操作的请求体:
|
||||
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
|
||||
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
|
||||
|
||||
@@ -861,8 +861,14 @@ func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 队员同理:一条报名 0..N 个,单独查一次按 entry_id 挂上(空数组 = 只报单位)。
|
||||
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName)
|
||||
out[i].Athletes = entryAthletesOf(athletes[out[i].ID])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -525,6 +525,10 @@ type entryImportOutcome struct {
|
||||
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
|
||||
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
|
||||
StatusChanged bool
|
||||
// MembersChanged 表示这次更新改动了报名的**队员关联**(换人、加人)。
|
||||
// 队员决定谁拿到个人分与个人奖牌,所以它必须重算——与 status 跨界同一类「谁还算数」的改动,
|
||||
// 而与姓名 / 号码布 / 队伍 / 批次那类纯展示字段不同(那些改了不重算)。
|
||||
MembersChanged bool
|
||||
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
|
||||
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
|
||||
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
|
||||
@@ -693,6 +697,9 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
|
||||
if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
if _, err := s.syncImportedEntryMembers(ctx, newID, in); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
|
||||
if len(moved) > 0 {
|
||||
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
|
||||
@@ -722,14 +729,82 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
|
||||
if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
membersChanged, err := s.syncImportedEntryMembers(ctx, existing, in)
|
||||
if err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return entryImportOutcome{
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
MembersChanged: membersChanged,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// syncImportedEntryMembers 把导入这一行认出来的队员整组写到报名上(新增与更新两条路都要调),
|
||||
// 返回这次有没有真的改动队员关联。
|
||||
//
|
||||
// **一格认出了人才重写**:认出了号码布的一格就是这条报名的完整队员名单 —— 整组重写(先删后插,
|
||||
// 与份额同一口径),重传同一行幂等,删掉一个号码布那个人就跟着从队里出去。
|
||||
// 一个号码布都认不出来(整列留空、只写姓名、或号码布认不出人)时这一格**只当文字**,
|
||||
// 已有的队员关联一行都不动:
|
||||
// - 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移按「号码布优先、其次姓名+单位唯一」回填
|
||||
// 出来的队员,不能因为重传一份没有号码布的名单就被悄悄清掉(那等于让人无声无息掉出个人积分榜);
|
||||
// - 界面上勾好的队员也不能被「用模板重导一遍」冲掉(模板的队员名单列本来就是空的);
|
||||
// - 要清空队员走界面(PUT /api/entries/{id}/athletes 传空数组),那是明确表达出来的意图。
|
||||
// 这条与「认不上就只当文字、别报错」是同一句话:认不出的格子对这条报名不产生任何影响。
|
||||
//
|
||||
// 返回值只比**人的集合**,不比顺序:队员顺序只影响「队里有谁」的排列,名次与积分不看它
|
||||
// (见 entry_athletes 表注释),所以只是换了个写法重传不该把这一组白算一遍。
|
||||
// 关联真的变了就必须重算——队员决定谁拿到个人分与个人奖牌,不重算就是「名单里换人了、榜上还是旧人」。
|
||||
//
|
||||
// 带运动员的报名一个字节都不写:它的身份就是那一个运动员,队员只属于「只报单位」的团体报名
|
||||
// (与 PUT /api/entries/{id}/athletes 的同一条口径)。
|
||||
func (s *server) syncImportedEntryMembers(ctx context.Context, entryID int64, in entryDTO) (bool, error) {
|
||||
if in.AthleteID != nil || len(in.Athletes) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
before, err := entryAthleteIDsQ(ctx, s.ev, entryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
after := entryAthleteIDs(in.Athletes)
|
||||
if err := replaceEntryAthletes(ctx, s.ev, entryID, in.Athletes); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !slices.Equal(before, after), nil
|
||||
}
|
||||
|
||||
// entryAthleteIDsQ 读一条报名当前的队员 id(升序),用于判断这次导入有没有改动关联。
|
||||
func entryAthleteIDsQ(ctx context.Context, q queryer, entryID int64) ([]int64, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT athlete_id FROM entry_athletes WHERE entry_id = ? ORDER BY athlete_id`, entryID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := []int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// entryAthleteIDs 把要写进去的队员摊成升序的 id 列表(与 entryAthleteIDsQ 同一种形状好比较)。
|
||||
func entryAthleteIDs(athletes []entryAthleteDTO) []int64 {
|
||||
out := make([]int64, 0, len(athletes))
|
||||
for _, a := range athletes {
|
||||
out = append(out, a.AthleteID)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。
|
||||
//
|
||||
// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串,
|
||||
|
||||
@@ -244,8 +244,14 @@ func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 队员(devdocs/20)同理随报名进引擎:每个队员各拿一份完整的个人分(见 domain 的 memberCopy)。
|
||||
members, err := loadEntryAthletesQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].TeamShares = shares[out[i].ID]
|
||||
out[i].Athletes = members[out[i].ID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -571,6 +577,11 @@ func resultStatusPtr(s domain.ResultStatus) *string {
|
||||
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
|
||||
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
|
||||
//
|
||||
// 队员的个人副本(MemberCopy)一律不写回:它们是报名项那一份的复制品,写回等于让「同一条报名
|
||||
// 的每个队员」轮流改写同一个录入位——团体报名项的 results.rank 是团体手工通道的输入,
|
||||
// 被队员副本覆盖后,第二次发布会把自动名次当成团体名次再发一遍分(A-1 的同一个坑)。
|
||||
// 该写的那一份是报名项自己(上面第一条规则已经管着它),一条成绩始终只有一个写入者。
|
||||
//
|
||||
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
|
||||
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
|
||||
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
|
||||
@@ -583,6 +594,9 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
if rr.MemberCopy {
|
||||
continue
|
||||
}
|
||||
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -282,6 +282,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
|
||||
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
|
||||
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
|
||||
// 队员(devdocs/20):整组替换一条团体报名的队员,改完当场重算并推榜——队员决定谁拿个人分、个人奖牌。
|
||||
mux.HandleFunc("PUT /api/entries/{id}/athletes", s.write(auth.RoleRecorder, s.updateEntryAthletesHandler))
|
||||
|
||||
// 成绩
|
||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- 团体报名的「队员身份」(devdocs/20 的实现)。
|
||||
--
|
||||
-- 背景:双打这类团体 / 对抗项目,界面本来就能一次勾多个运动员(EntryPicker),但提交时只把
|
||||
-- 姓名拼成文字写进 entries.member_names —— 系统只知道「这条报名属于某单位」,不知道队里坐着谁,
|
||||
-- 于是所有配了个人通道的团体项目都给不出个人分、个人奖牌,上不了个人积分榜。
|
||||
--
|
||||
-- 表达方式:**队员是报名的人员关联,不是新的一条报名**。一条团体报名 0..N 个队员
|
||||
-- (0 个 = 只报单位,与升级前逐字节一致)。队员与「队伍」(0025 的 entries.squad)是两个
|
||||
-- 正交的维度:squad 是「这个单位派的第几支队」,队员是「这支队的队员是谁」,可以叠加。
|
||||
-- **报名唯一键 (category_id, team_id, squad) WHERE athlete_id IS NULL 一个字都不动**:
|
||||
-- 队员不参与报名去重(同一个人在同一组别的两条报名里出现是允许的,见 devdocs/20 §五)。
|
||||
--
|
||||
-- member_names 保留:它是**显示用的文字名单**(导出、大屏、录入页都读它),老数据靠它,
|
||||
-- 也是「人没录档案、只登记不关联」的降级形态。人员身份只认这张表。
|
||||
--
|
||||
-- 老数据回填(尽力而为,匹配不上就不关联,绝不硬猜):
|
||||
-- 1) 号码布优先:member_names 里的每一项先按 athletes.bib 认人(0014 有部分唯一索引,
|
||||
-- 号码布是运动员的唯一标识);
|
||||
-- 2) 其次「姓名 + 单位」,且**同单位同名的人只有一个**时才认(重名留给人工确认);
|
||||
-- 3) 其余留空 = 维持升级前的行为(只报单位,上不了个人榜)。
|
||||
-- 回填只在升级那一刻做一次(写进 schema_migrations),不会在每次启动时重跑。它是
|
||||
-- INSERT OR IGNORE:万一被重放(schema_migrations 少了一行被补跑),已有的关联——包括人工
|
||||
-- 确认过的——一行都不会被覆盖或重复,最多补上「还没有的关联」(与 0024 的份额回填同一口径)。
|
||||
-- 回填只碰**纯团体报名**(athlete_id IS NULL):带运动员的报名本来就带身份,不需要队员。
|
||||
--
|
||||
-- 回填之后,配了个人通道(scope=athlete)的老团体项目会开始给队员发个人分 / 个人奖牌
|
||||
-- ——这正是本单的目的;**单位维度(单位总分、单位奖牌)不受影响**(见 domain 侧的口径与
|
||||
-- httpapi 的端到端对拍)。只配了团体通道的球类项目(现场绝大多数)一个字节都不变。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_athletes (
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
athlete_id INTEGER NOT NULL REFERENCES athletes(id) ON DELETE CASCADE,
|
||||
-- 展示与流水顺序都用它:写侧按请求给的顺序编号,老数据回填按 member_names 里的书写顺序。
|
||||
-- 名次与积分不依赖顺序(每个队员各拿一整份),它只影响「队里有谁」的排列。
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
-- 同一条报名里同一个人只能出现一次(重复 = 同一条成绩给同一个人记两份分、两枚牌)。
|
||||
PRIMARY KEY (entry_id, athlete_id)
|
||||
);
|
||||
|
||||
-- 不另建 entry_id 索引:主键 (entry_id, athlete_id) 的索引已经以 entry_id 打头,
|
||||
-- 读侧的「按小项批量取队员」走的正是它(与 0024 的份额表同一个取舍)。
|
||||
-- athlete_id 上的级联删除(人员被彻底删除时带走关联行)走全表扫描,但这张表只有报名量级。
|
||||
|
||||
-- 回填 1:号码布优先。member_names 里写号码布(导入规范允许)时按号码布认人。
|
||||
-- json_valid 的守卫是必须的:member_names 在库里是 JSON 数组文本,但历史上不排除被手工写成
|
||||
-- 别的样子;json_each 遇到坏 JSON 会直接报错,那会让整个升级失败、现场开不了机。
|
||||
-- 坏值按「没有名单」处理('[]'),等价于不回填这条报名。
|
||||
INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order)
|
||||
SELECT e.id, a.id, je.key
|
||||
FROM entries e
|
||||
JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je
|
||||
JOIN athletes a ON a.bib = TRIM(je.value)
|
||||
WHERE e.athlete_id IS NULL
|
||||
AND TRIM(COALESCE(je.value, '')) <> '';
|
||||
|
||||
-- 回填 2:姓名 + 单位。同单位同名不止一个时(重名)不认——猜错会把分记到另一个人头上,
|
||||
-- 而人还能事后在界面上补;猜错的分却已经上了榜。
|
||||
INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order)
|
||||
SELECT e.id, a.id, MIN(je.key)
|
||||
FROM entries e
|
||||
JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je
|
||||
JOIN athletes a ON a.name = TRIM(je.value) AND a.team_id = e.team_id
|
||||
WHERE e.athlete_id IS NULL
|
||||
AND TRIM(COALESCE(je.value, '')) <> ''
|
||||
-- 上面那条已经按号码布认过人时不再按姓名认第二遍(同一条报名里同一个人只能一行,主键兜着,
|
||||
-- 这条只是让「认了谁」这件事一眼可读)。
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_athletes ea WHERE ea.entry_id = e.id AND ea.athlete_id = a.id)
|
||||
AND (SELECT COUNT(*) FROM athletes a2 WHERE a2.name = TRIM(je.value) AND a2.team_id = e.team_id) = 1
|
||||
GROUP BY e.id, a.id;
|
||||
@@ -0,0 +1,242 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
// 0026:团体报名的「队员身份」(entry_athletes)。
|
||||
//
|
||||
// 四条底线:
|
||||
// - 老数据零变化:既有表(entries / results / score_ledger / channel_ranks)一行一列都不改,
|
||||
// 新增的只有这张关联表;
|
||||
// - 回填**以号码布为准**、其次「姓名 + 单位」,且只在「同单位同名唯一」时才认,重名与查无此人
|
||||
// 一律留空(不硬猜:猜错就是把某人的分记到另一个人头上);
|
||||
// - 只碰纯团体报名(athlete_id IS NULL):带运动员的报名自己就是那个人的身份;
|
||||
// - 幂等:重复执行迁移(含把这条 SQL 重放一遍)不报错、不重复回填、不覆盖人工改过的关联。
|
||||
func TestMigration0026EntryAthletes(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
applyBefore(t, db, "0026") // 回到「升级前」:库结构到 0025 为止
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
|
||||
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
|
||||
// 号码布是运动员的唯一标识(0014 上有部分唯一索引)。
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('张伟', '男', 1, 'A001')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A002')`,
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
|
||||
// 同单位两个同名的人:回填不许猜(下面这条报名里写的「李强」认不出是哪个)。
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`,
|
||||
// 另一个单位的运动员:号码布能认到他(号码布不分单位),姓名+单位认不到。
|
||||
`INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '女', 2, 'B001')`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`,
|
||||
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
|
||||
// 1) 姓名 + 单位都认得上:张伟 + 王五(顺序也要留住)。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (1, 1, 1, '["张伟","王五"]', 'registered')`,
|
||||
// 2) 号码布优先:写的是 A002(同一个单位的王五)与 B001(另一个单位的赵六)。
|
||||
// 用队伍名与上一条区分开(一个单位在一个组别只能有一条不分队的团体报名,见 0025)。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
||||
VALUES (1, 1, 1, '["A002","B001"]', 'registered', '二队')`,
|
||||
// 3) 重名(同单位两个李强):不认。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status)
|
||||
VALUES (1, 1, 2, '["李强"]', 'registered')`,
|
||||
// 4) 查无此人:不认。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
||||
VALUES (1, 1, 2, '["查无此人"]', 'registered', '二队')`,
|
||||
// 5) 带运动员的报名:本来就有身份,不回填。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
|
||||
VALUES (1, 1, 1, 1, '["张伟"]', 'registered')`,
|
||||
// 6) member_names 是坏 JSON(历史上被手工写过非 JSON 文本):按「没有名单」处理,迁移不许失败。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad)
|
||||
VALUES (1, 1, 2, '张伟/李强', 'registered', '三队')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造老库数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 升级前:这张表还不该存在。
|
||||
if _, err := db.Exec(`SELECT count(*) FROM entry_athletes`); err == nil {
|
||||
t.Fatal("升级前不应存在 entry_athletes 表")
|
||||
} else if !strings.Contains(err.Error(), "entry_athletes") {
|
||||
t.Fatalf("查询报错应点明缺表 entry_athletes,实际 %v", err)
|
||||
}
|
||||
|
||||
// 既有表在升级前的原始内容:升级后必须逐字节一致。
|
||||
untouched := []string{
|
||||
`SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0), COALESCE(member_names, ''),
|
||||
COALESCE(bib, ''), COALESCE(seed, ''), COALESCE(status, ''), squad
|
||||
FROM entries ORDER BY id`,
|
||||
}
|
||||
before := make([]string, len(untouched))
|
||||
for i, q := range untouched {
|
||||
before[i] = tableText(t, db, q)
|
||||
}
|
||||
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("升级赛事库: %v", err)
|
||||
}
|
||||
|
||||
// 回填结果:只有「认得出」的两种情形落行,顺序按 member_names 的书写顺序。
|
||||
want := map[int64][]string{
|
||||
1: {"1:0", "2:1"}, // 张伟、王五
|
||||
2: {"2:0", "5:1"}, // 号码布 A002 → 王五;号码布 B001 → 赵六(另一个单位也认)
|
||||
3: {}, // 重名不猜
|
||||
4: {}, // 查无此人不猜
|
||||
5: {}, // 带运动员的报名不回填
|
||||
6: {}, // 坏 JSON:按没有名单处理
|
||||
}
|
||||
got := entryAthletesText(t, db)
|
||||
for entryID, wantRows := range want {
|
||||
if strings.Join(got[entryID], ",") != strings.Join(wantRows, ",") {
|
||||
t.Errorf("报名 %d 回填出 %v,期望 %v", entryID, got[entryID], wantRows)
|
||||
}
|
||||
}
|
||||
|
||||
// 老数据零变化:既有表的每一行每一个字段都没动。
|
||||
for i, q := range untouched {
|
||||
if after := tableText(t, db, q); after != before[i] {
|
||||
t.Fatalf("升级改动了既有数据(第 %d 条对拍):\n之前\n%s之后\n%s", i+1, before[i], after)
|
||||
}
|
||||
}
|
||||
|
||||
// 表结构:主键 (entry_id, athlete_id) 保证同一条报名里同一个人只有一行。
|
||||
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 9)`); err == nil {
|
||||
t.Error("同一条报名里同一个运动员重复应被主键拒绝")
|
||||
}
|
||||
// 外键:报名 / 人员不存在都写不进去。
|
||||
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (999, 1)`); err == nil {
|
||||
t.Error("entry_id 外键应拦住不存在的报名")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (1, 999)`); err == nil {
|
||||
t.Error("athlete_id 外键应拦住不存在的人员")
|
||||
}
|
||||
|
||||
// 幂等:再跑一次迁移,内容不变。
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("重复升级应无副作用: %v", err)
|
||||
}
|
||||
if after := entryAthletesText(t, db); fmt.Sprint(after) != fmt.Sprint(got) {
|
||||
t.Fatalf("重复迁移改动了队员关联:\n之前 %v\n之后 %v", got, after)
|
||||
}
|
||||
// 迁移被重放(schema_migrations 少一行被补跑)时:已有的关联(含人工确认过的)不被覆盖、
|
||||
// 不产生重复行(INSERT OR IGNORE 的口径与 0024 的份额回填一致)。
|
||||
if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 7)`); err != nil {
|
||||
t.Fatalf("人工补一条关联: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(readMigration(t, "0026")); err != nil {
|
||||
t.Fatalf("重放 0026: %v", err)
|
||||
}
|
||||
replayed := entryAthletesText(t, db)
|
||||
if rows := replayed[3]; strings.Join(rows, ",") != "3:7" {
|
||||
t.Errorf("重放改动了人工确认的关联(顺序必须原样保留):%v", rows)
|
||||
}
|
||||
if rows := replayed[1]; strings.Join(rows, ",") != "1:0,2:1" {
|
||||
t.Errorf("重放后报名 1 的关联应保持不变、也不重复:%v", rows)
|
||||
}
|
||||
|
||||
// 级联:报名删掉带走它的队员行;人员被彻底删除也带走(purgeAthlete 走的就是这条外键)。
|
||||
if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil {
|
||||
t.Fatalf("删报名: %v", err)
|
||||
}
|
||||
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE entry_id = 2`); got != 0 {
|
||||
t.Errorf("报名删掉后队员行应级联清空,还剩 %d 条", got)
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM athletes WHERE id = 5`); err != nil {
|
||||
t.Fatalf("删人员: %v", err)
|
||||
}
|
||||
if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE athlete_id = 5`); got != 0 {
|
||||
t.Errorf("人员删掉后队员行应级联清空,还剩 %d 条", got)
|
||||
}
|
||||
|
||||
// 外键悬挂检查。
|
||||
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
|
||||
if err != nil {
|
||||
t.Fatalf("外键检查: %v", err)
|
||||
}
|
||||
defer func() { _ = fkRows.Close() }()
|
||||
if fkRows.Next() {
|
||||
t.Error("迁移后存在外键悬挂")
|
||||
}
|
||||
|
||||
// 迁移登记数对得上(这条迁移被记账一次)。
|
||||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// entryAthletesText 把队员关联按报名归组导成 "%d:%d"(运动员 id:顺序)的稳定文本。
|
||||
func entryAthletesText(t *testing.T, db *sql.DB) map[int64][]string {
|
||||
t.Helper()
|
||||
out := make(map[int64][]string)
|
||||
rows, err := db.Query(`SELECT entry_id, athlete_id, sort_order FROM entry_athletes
|
||||
ORDER BY entry_id, sort_order, athlete_id`)
|
||||
if err != nil {
|
||||
t.Fatalf("读队员关联: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID, athleteID, order int64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &athleteID, &order); err != nil {
|
||||
t.Fatalf("扫描队员关联: %v", err)
|
||||
}
|
||||
out[entryID] = append(out[entryID], fmt.Sprintf("%d:%d", athleteID, order))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历队员关联: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tableText 把一条查询的全部行拼成一份对拍文本(列之间用 | 连)。
|
||||
func tableText(t *testing.T, db *sql.DB, query string) string {
|
||||
t.Helper()
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("查询 %q: %v", query, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatalf("读列名: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for rows.Next() {
|
||||
cells := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range cells {
|
||||
ptrs[i] = &cells[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
t.Fatalf("扫描 %q: %v", query, err)
|
||||
}
|
||||
for i, c := range cells {
|
||||
if i > 0 {
|
||||
b.WriteString("|")
|
||||
}
|
||||
switch v := c.(type) {
|
||||
case nil:
|
||||
b.WriteString("<nil>")
|
||||
case []byte:
|
||||
b.Write(v)
|
||||
default:
|
||||
fmt.Fprintf(&b, "%v", v)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历 %q: %v", query, err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in new issue
Block a user