用户答复「允许细化」——给另外两个榜单各做一份逐项来源导出(此前它们的「明细」共用 单位积分明细、个人榜连明细列都没有)。 - `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,含上一轮"个人积分榜不能导出"的修正)。
271 lines
8.7 KiB
Go
271 lines
8.7 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|