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,含上一轮"个人积分榜不能导出"的修正)。
This commit is contained in:
AskaEth committed 2026-09-11 01:17:14 +08:00
1 parent 782a38f82f
commit 8e707e1e9b
15 files changed
+1546 -45

No files matched your search

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