Files
Scoreboard/server/internal/httpapi/entry_squad_test.go
T
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
2026-09-14 11:45:32 +08:00

332 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 队伍(squad):同一个单位在同一个组别可以派多个队参赛(羽毛球双打那种:一个单位两个队,
// 两个队各占一个名次)。
//
// 口径(迁移 0025):
// - squad 是自由文本(一队 / 二队 / 青年队 都能写),**空串 = 不区分队伍**——老数据与单队单位的
// 默认值,行为与升级前一模一样(一个单位在这个组别一条团体报名);
// - 只对**纯团体报名**(athlete_id 为空)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,
// 同一个人在同一组别不会因为这次改动而报两次;
// - 一个单位两个队都进前三就是 2 枚牌,引擎本来就按报名逐条记牌,不走特判。
// importHeaderSquad 是带「队伍」列的表头:新列**加在最后**,既有表格一列不错位。
const importHeaderSquad = importHeader + ",squad"
// importHeaderSquadZH 是中文别名表头(队伍 = importColumns 里 squad 的首选别名)。
const importHeaderSquadZH = importHeaderZH + ",队伍"
// squadImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeaderSquad 一致。
func squadImportRow(eventCode, category, teamCode, squad, members string) string {
return strings.Join([]string{eventCode, category, teamCode, "",
"", "", "", "", "", "registered", members, squad}, ",")
}
// importCSVWithHeader 用指定表头上传一份单表 CSV。
func importCSVWithHeader(t *testing.T, env *testEnv, client *http.Client, header string, rows ...string) (int, envelope) {
t.Helper()
content := header + "\n" + strings.Join(rows, "\n") + "\n"
return env.postImport(t, client, "/api/entries/import", content, "")
}
// seedDoublesEvent 建一个「羽毛球双打」形态的小项:team 类型 + 按成绩排名的团体通道
// (团体报名项各录各的成绩、各占各的名次),返回小项与组别 id。
func seedDoublesEvent(t *testing.T, env *testEnv, ids seedIDs) (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 (?, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
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 (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
return eventID, categoryID
}
// squadDump 导出某小项「单位 → 队伍」的落库事实(按报名 id 排序,稳定可比)。
// 刻意不带报名 id:id 由种子数据占位,断言里写死 id 只会让用例变脆。
func squadDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT t.code AS team_code, e.squad
FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = %d ORDER BY e.id`, eventID))
}
// 现场场景:一个单位派两个队报名。升级前第二条会被唯一索引拦住(一条团体报名),
// 现在两条都要落库、各自有名次、各自拿牌。
func TestImportSquadCreatesSecondTeamOfSameUnit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if len(result.Errors) != 0 {
t.Fatalf("不该有报错行:%v", result.Errors)
}
if result.Created != 3 {
t.Fatalf("一个单位两个队应是 3 条报名(A 两条 + B 一条),得到 created=%d updated=%d:%s",
result.Created, result.Updated, squadDump(t, env, eventID))
}
// 落库:两个队的 squad 各自留着,空串那条就是老口径「不区分队伍」。
rows, err := env.ev.Query(`SELECT t.code, e.squad FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? ORDER BY e.id`, eventID)
if err != nil {
t.Fatalf("读报名: %v", err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var code, squad string
if err := rows.Scan(&code, &squad); err != nil {
t.Fatalf("扫描报名: %v", err)
}
got = append(got, code+":"+squad)
}
want := "A:一队 A:二队 B:"
if joined := strings.Join(got, " "); joined != want {
t.Fatalf("落库的队伍不对:\n得到 %s\n期望 %s", joined, want)
}
// 名次:一队第 1、二队第 2、B 第 3 —— 两个队都进前三,A 拿 2 枚牌(金 1 银 1)。
publishResults(t, env, client, eventID, "squad",
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"},
)
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
// 报名列表要把队伍带出来(前端据此区分「A 一队 / A 二队」)。
board := getEntryBoard(t, env, client, eventID)
byID := map[float64]string{}
for _, entry := range board.Entries {
id, _ := entry["id"].(float64)
squad, _ := entry["squad"].(string)
byID[id] = squad
}
for _, want := range []struct {
squad string
}{{"一队"}, {"二队"}, {""}} {
id := float64(entryIDOfSquad(t, env, eventID, want.squad))
if byID[id] != want.squad {
t.Errorf("报名 %v 的队伍 = %q,期望 %q", id, byID[id], want.squad)
}
}
}
// 老表零变化:表里没有队伍列(或整列留空)时,一个单位在一个组别仍然只有一条团体报名——
// 第二条是**更新**那一条,不是新增(升级前的行为一字不改)。
func TestImportSquadKeepsLegacyBehaviourWhenColumnEmpty(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
// 先走升级前的老表头(连队伍列都没有)。
status, resp := importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("不带队伍列的老表应照常导入,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("老表应新建 1 条:%+v", result)
}
// 同一份老表再传一次:更新那一条,不新增(老行为)。
status, resp = importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("老表重传应照常更新,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一份老表重传应是「新建 0 / 更新 1」,得到 %+v", result)
}
// 带队伍列的表格、但那一格留空:同样是不区分队伍的老口径(B 与 A 各一条)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "B", "", "孙七/周八"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("队伍留空应照常导入,得到 %d %+v", status, resp.Error)
}
want := "team_code=A squad= \nteam_code=B squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老口径落库不对:\n得到 %s期望 %s", got, want)
}
}
// 同一个队重复导入是幂等的(后一行覆盖前一行);换一个队伍名才是第二个队。
func TestImportSquadSameTeamTwiceIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强/王五"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一个队重传应是「新建 1 / 更新 1」(后一行覆盖前一行),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个队重传后库里应只有 1 条报名,得到 %d 条:%s", got, squadDump(t, env, eventID))
}
// 后一行覆盖前一行:队员名单以最后一行写的为准。
if got := tableDump(t, env, fmt.Sprintf(`SELECT member_names FROM entries WHERE event_id = %d`, eventID)); !strings.Contains(got, "王五") {
t.Fatalf("后一行应覆盖前一行的队员名单,得到 %s", got)
}
// 换一个队伍名 = 第二个队:新增一条。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "二队", "李四/赵六"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入第二个队应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("第二个队应新增 1 条:%+v", result)
}
}
// 中文别名:队伍 / 队名 / 队伍名 都认(现场拿到的是中文模板)。
func TestImportSquadChineseAliases(t *testing.T) {
for _, alias := range []string{"队伍", "队名", "队伍名"} {
t.Run(alias, func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
header := strings.TrimSuffix(importHeaderSquadZH, "队伍") + alias
status, resp := importCSVWithHeader(t, env, client, header,
squadImportRow("BADM", "男子", "A", "青年队", "张伟/李强"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("中文表头 %s 应能导入,得到 %d %+v", alias, status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("应新建 1 条:%+v", result)
}
want := "team_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("中文表头落库不对:\n得到 %s期望 %s", got, want)
}
})
}
}
// 带运动员的报名不受队伍影响:同一个人在同一组别写两个队伍名,仍是同一条报名(不会报两次),
// 并且要有一条 notices 说清「队伍没写进库里」——那一格填了就不该悄悄消失。
func TestImportSquadIgnoredForAthleteRows(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 另开一个个人小项:种子里的 M100 有两条报名,掺进来没法一眼看清新增了几条。
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
athleteRow := func(squad string) string {
return strings.Join([]string{"M200", "男子", "A", "张三", "110101199001011234", "男", "26",
"101", "", "registered", "", squad}, ",")
}
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("一队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 {
t.Fatalf("个人行应新建 1 条:%+v", result)
}
if joined := strings.Join(result.Notices, ";"); !strings.Contains(joined, "队伍只对团体报名") {
t.Fatalf("个人行填了队伍要给一条提示,得到 %v", result.Notices)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND squad <> ''`, eventID); got != 0 {
t.Fatalf("个人报名的 squad 必须是空串,得到 %d 条非空", got)
}
// 同一个人换个队伍名重传:还是同一条(同一个人在同一组别不会因为队伍报两次)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("二队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
}
result = decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 {
t.Fatalf("同一个人换个队伍名重传应仍是那一条(新建 0 / 更新 1),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个人在同一组别应只有 1 条报名,得到 %d 条", got)
}
}
// 模板要在最后多一列「队伍 / squad」:新列追加在表尾,现场手里少一列的旧表格一列都不会错位。
func TestEntriesTemplatePutsSquadLast(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, tc := range []struct{ lang, want string }{{"zh", "队伍"}, {"en", "squad"}} {
f, _ := env.fetchTemplate(t, client, tc.lang)
rows, err := f.GetRows(f.GetSheetList()[0])
if err != nil {
t.Fatalf("[%s] 读模板: %v", tc.lang, err)
}
header := rows[0]
if len(header) != 13 {
t.Fatalf("[%s] 模板表头应是 13 列(原 12 列 + 队伍),得到 %d 列:%v", tc.lang, len(header), header)
}
if header[len(header)-1] != tc.want {
t.Fatalf("[%s] 队伍列必须在最后一列(%q),得到 %q", tc.lang, tc.want, header[len(header)-1])
}
}
}
// 老模板(12 列,没有队伍列)照常导入:这是「新列加在最后」的全部意义——
// 现场手里那些列少的表格一列都不会错位,导入结果与升级前完全一样。
func TestImportSheetTemplateWithoutSquadColumnStillWorks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
const legacyHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
data := buildTestXLSX(t, []testSheet{
{name: "羽毛球双打@男子", rows: [][]string{
strings.Split(legacyHeader, ","),
{"", "", "A", "", "", "", "", "301", "", "1", "registered", "张伟/李强"},
}},
})
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
if status != http.StatusOK || !resp.OK {
t.Fatalf("老模板应照常导入,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("应新建 1 条且无报错:%+v", result)
}
want := "team_code=A squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老模板落库的队伍应是空串(不区分队伍):\n得到 %s期望 %s", got, want)
}
}
// entryIDOfSquad 取某小项里某个队的报名 id(空串 = 不区分队伍的那条)。
func entryIDOfSquad(t *testing.T, env *testEnv, eventID int64, squad string) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND squad = ? ORDER BY id LIMIT 1`,
eventID, squad).Scan(&id); err != nil {
t.Fatalf("读队伍 %q 的报名: %v", squad, err)
}
return id
}