Files
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

175 lines
8.3 KiB
Go

package event
import (
"database/sql"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0025:团体项目的「队伍」(entries.squad)。
//
// 三条底线:
// - 老数据零变化:升级后既有报名的 squad 一律是空串,一个单位在一个组别仍然只能有一条
// 纯团体报名(唯一索引换成带 squad 的版本后,空串这一档的约束与升级前**等价**);
// - 只给纯团体报名放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,同一个人在同一组别
// 不会因为这次改动多报一次(表级 UNIQUE + 新的部分索引都不覆盖 athlete_id 非空的行);
// - 幂等:重复执行迁移不报错、不改动任何一行。
func TestMigration0025EntrySquad(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0025") // 回到「升级前」:库结构到 0024 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
`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)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`,
// 团体报名(athlete_id 为空)与个人报名各一条,升级后都要一字不动。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status)
VALUES (1, 1, 1, NULL, '["张伟","李强"]', 'registered')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, 1, 'registered')`,
}
// 升级前:老索引拦住第二个队 —— 同一个单位在同一个组别只能有一条团体报名。
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 1, NULL, 'registered')`); err == nil {
t.Fatal("升级前同一个单位的第二条团体报名应被唯一索引拦住")
}
if _, err := db.Exec(`SELECT squad FROM entries`); err == nil {
t.Fatal("升级前不该有 squad 列")
} else if !strings.Contains(err.Error(), "squad") {
t.Fatalf("查询报错应点明缺列 squad,实际 %v", err)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 老数据零变化:既有报名一条不少、squad 一律空串。
if got := countRows(t, db, "entries"); got != 2 {
t.Fatalf("升级后报名数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad <> ''`); got != 0 {
t.Errorf("老数据的 squad 必须是空串(不区分队伍),得到 %d 条非空", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id = 1 AND squad <> ''`); got != 0 {
t.Errorf("老数据的 squad 必须全部是空串,得到 %d 条非空", got)
}
// 老索引没了,换成带 squad 的那条(部分索引:只管 athlete_id 为空的行)。
if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_unique'`); got != 0 {
t.Errorf("老索引 idx_entries_team_unique 应已被删除,还剩 %d 条", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_squad_unique'`); got != 1 {
t.Errorf("应存在新索引 idx_entries_team_squad_unique,得到 %d 条", got)
}
// 空串这一档的约束与升级前**等价**:同一单位再加一条不分队的团体报名仍然被拒绝。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 1, NULL, 'registered')`); err == nil {
t.Error("同一个单位同一个组别的第二条「不分队」团体报名应被拒绝(老口径不变)")
}
// 另一个单位的不分队报名照旧合法。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 2, NULL, 'registered')`); err != nil {
t.Fatalf("另一个单位的团体报名应合法: %v", err)
}
// 这就是这次改动要放开的事:同一个单位派两个队。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '一队')`); err != nil {
t.Fatalf("同一个单位的第二个队应能报名: %v", err)
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '二队')`); err != nil {
t.Fatalf("同一个单位的第三个队应能报名: %v", err)
}
// 同一个单位的同一个队仍然只能一条。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, NULL, 'registered', '一队')`); err == nil {
t.Error("同一个单位同一个组别的同一个队重复应被唯一索引拒绝")
}
// 带运动员的报名不受影响:同一个人换个单位也是同一个人,仍按 (组别, 单位, 运动员) 唯一。
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 2, 1, 'registered', '一队')`); err == nil {
t.Error("同一个人在同一组别同一单位再报一次应被表级 UNIQUE 拒绝(squad 不参与个人报名)")
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad)
VALUES (1, 1, 1, 1, 'registered', '一队')`); err != nil {
t.Fatalf("同一个人在另一个单位报名应合法: %v", err)
}
// squad 是 NOT NULL DEFAULT '':老代码(或漏写这一列的语句)插入后仍是空串,不会变成 NULL。
if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('C', '第三分公司')`); err != nil {
t.Fatalf("补一个单位: %v", err)
}
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status)
VALUES (1, 1, 3, NULL, 'registered')`); err != nil {
t.Fatalf("漏写 squad 的插入应照常成功(默认空串): %v", err)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad IS NULL`); got != 0 {
t.Errorf("squad 不允许为 NULL,得到 %d 条", got)
}
// 幂等:再跑一次迁移,行数与内容都不变(这条 SQL 里 ADD COLUMN 不可重放,靠记账跳过)。
before := dumpEntriesSquad(t, db)
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
if got := dumpEntriesSquad(t, db); got != before {
t.Fatalf("重复迁移改动了报名:\n之前 %s\n之后 %s", before, 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)
}
}
// dumpEntriesSquad 把报名按稳定顺序导成文本(含 upgrade 后新增的队伍)。
func dumpEntriesSquad(t *testing.T, db *sql.DB) string {
t.Helper()
var b strings.Builder
rows, err := db.Query(`SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(status, ''), squad
FROM entries ORDER BY id`)
if err != nil {
t.Fatalf("读报名: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id, teamID, athleteID int64
status, squad string
)
if err := rows.Scan(&id, &teamID, &athleteID, &status, &squad); err != nil {
t.Fatalf("扫描报名: %v", err)
}
fmt.Fprintf(&b, "%d:%d:%d:%s:%s\n", id, teamID, athleteID, status, squad)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历报名: %v", err)
}
return b.String()
}