Files
Scoreboard/server/internal/export/medals_test.go
T
AskaEth 8e707e1e9b feat: 团体奖牌 / 个人积分两个榜单的专属明细导出
用户答复「允许细化」——给另外两个榜单各做一份逐项来源导出(此前它们的「明细」共用
单位积分明细、个人榜连明细列都没有)。

- `GET /api/export/medals.xlsx` → `团体奖牌明细.xlsx`:总表 单位/大项/小项/组别/名次/奖牌/运动员-队
  + 每个有奖牌的单位一张分表(末尾小计 金X/银Y/铜Z);无奖牌的单位不出分表。
  去重与牌色判定**完全交给 domain**(每组调 domain.BuildMedalStandings),导出侧只做
  「同一报名项归一组」,不复制任何计奖规则——双计通道只记一枚奖牌因此自动成立。
- `GET /api/export/athletes.xlsx` → `个人积分总明细.xlsx`:总表 单位/运动员/大项/小项/组别/
  名次/成绩/得分/来源 + 每个有得分的单位一张分表(按单位分,不按人分)。
  得分取**记到个人的原始分**(与个人积分榜同一口径,不看 count_to_team、不乘折算系数)。
- 管理端三个榜单的卡片头各一个导出入口(导出总明细 / 导出奖牌明细 / 导出个人明细),
  展开单位时明细卡头是「导出该单位明细」;导出页仍是 5 项,两个新导出只在积分榜页触发。

护栏(跨端点一致性,都有实测断言):
- 奖牌明细逐单位与「团体奖牌榜」对齐,双计项目只出一行;
- 个人明细合计与「个人积分榜」一致,且把折算系数改成 0.5 后个人明细一分不变、单位总分变 5。

测试:服务端 12 包全绿(358 个测试函数);前端 vue-tsc 通过、190 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q72,含上一轮"个人积分榜不能导出"的修正)。
2026-09-11 01:17:14 +08:00

243 lines
8.3 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 export
import (
"database/sql"
"path/filepath"
"testing"
)
// addBronzeEntry 给 seedEvent 的小项补一条第 3 名,让一块金牌之外还有银、铜。
func addBronzeEntry(t *testing.T, db *sql.DB) {
t.Helper()
// entries 对(组别,单位,运动员)唯一,第 3 名要再派一名运动员。
stmts := []string{
`INSERT INTO athletes(name, gender, team_id) VALUES ('王六', '男', 2)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 3, '103')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 3, 3, '12.10', 12.10, 's', 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 团体奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
func TestExportMedalDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
addBronzeEntry(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出团体奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
// 4 行版式 + 3 块奖牌
if len(master) != 7 {
t.Fatalf("总表行数 = %d,期望 7", len(master))
}
header := []string{"单位", "大项", "小项", "组别", "名次", "奖牌", "运动员/队"}
for i, want := range header {
if got := cell(master, 3, i); got != want {
t.Errorf("表头第 %d 列 = %q,期望 %q", i, got, want)
}
}
first := []string{"第一分公司", "田径", "100米跑", "男子", "1", "金牌", "张三"}
for i, want := range first {
if got := cell(master, 4, i); got != want {
t.Errorf("首行第 %d 列 = %q,期望 %q", i, got, want)
}
}
if got := cell(master, 5, 5); got != "银牌" {
t.Errorf("第二行奖牌 = %q,期望 银牌", got)
}
if got := cell(master, 6, 5); got != "铜牌" {
t.Errorf("第三行奖牌 = %q,期望 铜牌", got)
}
if got := cell(master, 6, 0); got != "第二分公司" {
t.Errorf("第三行单位 = %q,期望 第二分公司", got)
}
// 分表列 = 总表去掉「单位」列 + 末尾小计。
gold, err := f.GetRows("第一分公司奖牌明细")
if err != nil {
t.Fatalf("读取金牌单位分表: %v", err)
}
if len(gold) != 6 { // 4 行版式 + 1 块奖牌 + 小计
t.Fatalf("第一分公司分表行数 = %d,期望 6", len(gold))
}
if got := cell(gold, 3, 0); got != "大项" {
t.Errorf("分表表头首列 = %q,期望 大项", got)
}
if got := cell(gold, 4, 5); got != "张三" {
t.Errorf("分表首行运动员 = %q,期望 张三", got)
}
if got := cell(gold, 5, 0); got != "小计" {
t.Errorf("分表小计行首列 = %q,期望 小计", got)
}
if got := cell(gold, 5, 4); got != "金 1 / 银 0 / 铜 0" {
t.Errorf("分表小计 = %q,期望 金 1 / 银 0 / 铜 0", got)
}
silver, err := f.GetRows("第二分公司奖牌明细")
if err != nil {
t.Fatalf("读取银铜单位分表: %v", err)
}
if len(silver) != 7 { // 4 行版式 + 银、铜各 1 块 + 小计
t.Fatalf("第二分公司分表行数 = %d,期望 7", len(silver))
}
if got := cell(silver, 6, 4); got != "金 0 / 银 1 / 铜 1" {
t.Errorf("分表小计 = %q,期望 金 0 / 银 1 / 铜 1", got)
}
}
// 奖牌明细必须与团体奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
func TestMedalDetailMatchesBoard(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
addBronzeEntry(t, db)
// 再造一个双计项目:同一条成绩在 athlete + team 两条通道下各产出一条名次。
stmts := []string{
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E2', '100米跑(双计)', 'individual_team', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`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 event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'team_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '301')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
SELECT 2, id, 1, '11.00', 11.00, 's', 'published' FROM entries WHERE event_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
// 双计项目只贡献一枚金牌(去重成一条),不是两条。
dual := 0
total := 0
for _, s := range wb.Sheets {
for _, r := range s.Rows {
total++
if r.EventName == "100米跑(双计)" {
dual++
}
}
}
if dual != 1 {
t.Errorf("双计项目奖牌明细行数 = %d,期望 1(同一报名项只记一枚)", dual)
}
if total != 4 { // 3 块单通道奖牌 + 1 块双计奖牌
t.Errorf("奖牌明细总行数 = %d,期望 4", total)
}
for i, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
t.Logf("单位 %d:明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d", row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d(榜第 %d 名)",
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze, i+1)
}
if gold+silver+bronze == 0 {
// 榜上有名字但没有奖牌的单位不应出现在明细里。
for _, s := range wb.Sheets {
if s.Team.ID == row.TeamID {
t.Errorf("无奖牌单位 %d 不应出分表", row.TeamID)
}
}
}
}
}
// 没有奖牌(甚至没有小项)时不报错,返回只有表头的总表。
func TestExportMedalDetailEmptyEvent(t *testing.T) {
db := openEventDB(t)
insertTeam(t, db, "T1", "第一分公司", "一公司", 1, true)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("无奖牌导出: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
if got := f.GetSheetList(); !equalStrings(got, []string{"团体奖牌明细"}) {
t.Fatalf("sheet 列表 = %v,期望只有总表", got)
}
rows, err := f.GetRows("团体奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
if len(rows) != 4 { // 标题 / 副标题 / 空行 / 表头
t.Fatalf("总表行数 = %d,期望 4", len(rows))
}
if got := cell(rows, 3, 0); got != "单位" {
t.Errorf("表头首列 = %q,期望 单位", got)
}
}
// 已隐藏但有奖牌的单位在榜上,导出要与榜单一致地保留它。
func TestMedalDetailKeepsHiddenTeamWithMedals(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
silent := insertTeam(t, db, "H2", "被隐藏且无牌", "隐藏二", 4, false)
hideTeam(t, db, silent)
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
for _, s := range wb.Sheets {
if s.Team.ID == silent {
t.Fatalf("无奖牌的隐藏单位不应出分表: %+v", s.Team)
}
}
if len(wb.Sheets) != 2 {
t.Fatalf("分表数 = %d,期望 2(T1 金、T2 银)", len(wb.Sheets))
}
}
func medalCountsOf(wb MedalDetailWorkbook, teamID int64) (int, int, int) {
var gold, silver, bronze int
for _, s := range wb.Sheets {
if s.Team.ID != teamID {
continue
}
for _, r := range s.Rows {
switch r.Medal {
case medalGold:
gold++
case medalSilver:
silver++
case medalBronze:
bronze++
}
}
}
return gold, silver, bronze
}