Files
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

271 lines
8.7 KiB
Go
Raw Permalink 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"
)
func insertAthlete(t *testing.T, db *sql.DB, name string, teamID int64) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO athletes(name, gender, team_id) VALUES (?, '男', ?)`, name, teamID)
if err != nil {
t.Fatalf("造运动员 %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取运动员 id: %v", err)
}
return id
}
// 个人得分明细:总表 + 每个有得分的单位一张分表,逐条列出得分来源。
func TestExportAthleteDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 张三 10 分、T2 李四 8 分
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(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)
}
if len(master) != 6 { // 4 行版式 + 2 条流水
t.Fatalf("总表行数 = %d,期望 6", 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", "11.58 秒", "10", "成绩"}
for i, want := range first {
if got := cell(master, 4, i); got != want {
t.Errorf("首行第 %d 列 = %q,期望 %q", i, got, want)
}
}
// 单位分表 = 总表去掉「单位」列。
team, err := f.GetRows("第一分公司个人得分明细")
if err != nil {
t.Fatalf("读取单位分表: %v", err)
}
if len(team) != 5 { // 4 行版式 + 1 条流水
t.Fatalf("第一分公司分表行数 = %d,期望 5", len(team))
}
teamRow := []string{"张三", "田径", "100米跑", "男子", "1", "11.58 秒", "10", "成绩"}
for i, want := range teamRow {
if got := cell(team, 4, i); got != want {
t.Errorf("分表首行第 %d 列 = %q,期望 %q", i, got, want)
}
}
}
// 跨端点一致性:明细里某人的得分之和 == 个人积分榜上那个人的总分;
// 且折算系数只影响单位总分,个人明细必须一分不动。
func TestAthleteDetailMatchesBoardAndIgnoresWeight(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
assertAthleteDetailMatchesBoard(t, db)
if _, err := db.Exec(`UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
t.Fatalf("修改折算系数: %v", err)
}
// 系数 0.5 下重跑:个人明细与个人积分榜仍然一致(都是原始分 10 / 8)。
assertAthleteDetailMatchesBoard(t, db)
// 对照:同一份数据上单位总分被折半,证明系数确实生效,不是设置没写进去。
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总积分榜: %v", err)
}
for _, row := range board.Team {
if row.TeamID == 1 && row.Points != 5 {
t.Errorf("系数 0.5 时单位 1 总分 = %v,期望 5", row.Points)
}
}
// 单元格里必须是原始分 10,而不是折算后的 5。
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("系数 0.5 下导出个人得分明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("第一分公司个人得分明细")
if err != nil {
t.Fatalf("读取分表: %v", err)
}
if got := cell(rows, 4, 6); got != "10" {
t.Errorf("系数 0.5 时个人明细得分 = %q,期望 10(原始分,不折算)", got)
}
}
// 只记单位的流水不算个人得分;count_to_team = 0 的流水照样算在个人头上。
func TestAthleteDetailLedgerScope(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 仅记单位(athlete_id 为空):不进个人明细。
insertLedger(t, db, 1, 99)
// 「个人+团体」项目的个人通道流水:计入个人榜,但不进单位总分。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, 1, 1, 1, 1, 'result', 1, 7, 0)`); err != nil {
t.Fatalf("写入个人通道流水: %v", err)
}
wb := athleteDetailWorkbook(t, db)
if got := athletePointsFromDetail(wb, "张三"); got != 17 {
t.Errorf("张三明细得分 = %v,期望 17(10 + 只记个人的 7)", got)
}
board := athletePointsFromBoard(t, db)
if board[1] != 17 {
t.Errorf("个人积分榜张三总分 = %v,期望 17", board[1])
}
for _, s := range wb.Sheets {
for _, r := range s.Rows {
if r.Points == 99 {
t.Errorf("仅记单位的流水不应出现在个人明细: %+v", r)
}
}
}
}
// 没有得分流水的运动员不出现在任何一张表里。
func TestAthleteDetailSkipsAthleteWithoutLedger(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
insertAthlete(t, db, "王五", 1)
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出个人得分明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
for _, name := range f.GetSheetList() {
rows, err := f.GetRows(name)
if err != nil {
t.Fatalf("读取 %s: %v", name, err)
}
for r := range rows {
for c := 0; c < 9; c++ {
if cell(rows, r, c) == "王五" {
t.Errorf("%s 第 %d 行出现了无得分的运动员", name, r)
}
}
}
}
}
// 一条个人流水都没有时不报错:只有总表,能重新解析。
func TestExportAthleteDetailEmptyEvent(t *testing.T) {
db := openEventDB(t)
insertTeam(t, db, "T1", "第一分公司", "一公司", 1, true)
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(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 athleteDetailWorkbook(t *testing.T, db *sql.DB) AthleteDetailWorkbook {
t.Helper()
wb, err := LoadAthleteDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取个人得分明细: %v", err)
}
return wb
}
// athletePointsFromBoard 是「个人积分榜」端点同源的口径:domain.BuildAthleteStandings 的总分。
func athletePointsFromBoard(t *testing.T, db *sql.DB) map[int64]float64 {
t.Helper()
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取个人积分榜: %v", err)
}
out := make(map[int64]float64, len(wb.Athlete))
for _, row := range wb.Athlete {
out[row.AthleteID] = row.Points
}
return out
}
func athletePointsFromDetail(wb AthleteDetailWorkbook, name string) float64 {
var total float64
for _, s := range wb.Sheets {
for _, r := range s.Rows {
if r.AthleteName == name {
total += r.Points
}
}
}
return total
}
// assertAthleteDetailMatchesBoard 逐人比对:明细得分之和 == 个人积分榜总分;
// 并且两边的人必须一一对应(榜单上的人都有明细,明细里的人都在榜上)。
func assertAthleteDetailMatchesBoard(t *testing.T, db *sql.DB) {
t.Helper()
wb := athleteDetailWorkbook(t, db)
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取个人积分榜: %v", err)
}
byName := make(map[string]float64, len(board.Athlete))
for _, row := range board.Athlete {
byName[row.Name] = row.Points
}
detailNames := make(map[string]bool)
for _, s := range wb.Sheets {
for _, r := range s.Rows {
detailNames[r.AthleteName] = true
}
}
for name, want := range byName {
if !detailNames[name] {
t.Errorf("个人积分榜上的 %q 没有明细行", name)
continue
}
got := athletePointsFromDetail(wb, name)
t.Logf("运动员 %q:明细合计 %v,个人积分榜 %v", name, got, want)
if got != want {
t.Errorf("运动员 %q:明细合计 %v,个人积分榜 %v", name, got, want)
}
}
for name := range detailNames {
if _, ok := byName[name]; !ok {
t.Errorf("明细里的运动员 %q 不在个人积分榜上", name)
}
}
}