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 }