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`。
This commit is contained in:
AskaEth committed 2026-09-14 11:45:32 +08:00
1 parent 9663d5bfb0
commit 7a3e4ad365
67 files changed
+4658 -228

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
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
}