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

@@ -29,6 +29,9 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX))
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
@@ -94,6 +97,28 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
})
}
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteMedalDetailXLSX(out, wb)
})
}
// exportAthletesXLSX 导出个人得分明细:记到个人的流水逐条列出,总表 + 每个有得分的单位一张分表。
func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAthleteDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAthleteDetailXLSX(out, wb)
})
}
// exportAllTeamsXLSX 导出所有单位的积分明细:第一张总表 + 每个单位一张分表。
func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAllTeamDetailsWorkbook(s.ev, export.Options{})
@@ -0,0 +1,199 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
type athleteStandingRow struct {
AthleteID int64 `json:"athleteId"`
AthleteName string `json:"athleteName"`
TotalPoints float64 `json:"totalPoints"`
}
// seedScoredResults 给 seed 的报名项补成绩与积分流水:
// 单位 A 的运动员拿第 1 名 10 分,单位 B 的运动员拿第 2 名 8 分。
func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
t.Helper()
for _, in := range []struct {
entry int64
rank int
text string
value float64
team, athlete int64
points float64
}{
{ids.entryA, 1, "11.58", 11.58, ids.teamA, ids.athleteA, 10},
{ids.entryB, 2, "11.90", 11.90, ids.teamB, ids.athB, 8},
} {
e.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (?, ?, ?, ?, ?, 's', 'published')`,
ids.event, in.entry, in.rank, in.text, in.value)
e.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points)
SELECT ?, ?, ?, ?, id, 'result', 1, ? FROM results WHERE entry_id = ?`,
in.team, in.athlete, ids.event, in.entry, in.points, in.entry)
}
}
// guest 能下载团体奖牌明细;每块奖牌对应一条成绩。
func TestExportMedalsXLSXGuest(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.seedScoredResults(t, ids)
client, _ := env.client()
status, header, body := env.getRaw(t, client, "/api/export/medals.xlsx")
if status != http.StatusOK {
t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body)
}
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("团体奖牌明细.xlsx")
if got := header.Get("Content-Disposition"); got != wantCD {
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
// 单位 B 只有银牌也有分表;两个单位都有奖牌 → 总表 + 2 张分表。
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalSheetList(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 行版式 + 金、银各 1 块
t.Fatalf("总表行数 = %d,期望 6", len(master))
}
if got := masterCell(master, 4, 5); got != "金牌" {
t.Errorf("首行奖牌 = %q,期望 金牌", got)
}
if got := masterCell(master, 5, 5); got != "银牌" {
t.Errorf("次行奖牌 = %q,期望 银牌", got)
}
gold, err := f.GetRows("第一分公司奖牌明细")
if err != nil {
t.Fatalf("读取分表: %v", err)
}
if got := masterCell(gold, len(gold)-1, 4); got != "金 1 / 银 0 / 铜 0" {
t.Errorf("分表小计 = %q", got)
}
// 与团体奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。
_, _, standingsBody := env.getRaw(t, client, "/api/export/standings.xlsx")
sf := openWorkbook(t, standingsBody)
defer func() { _ = sf.Close() }()
medalRows, err := sf.GetRows("团体奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
if got := masterCell(medalRows, 4, 3); got != "1" {
t.Errorf("团体奖牌榜首队金牌 = %q,期望 1", got)
}
}
// guest 能下载个人得分明细;明细得分之和必须等于个人积分榜端点的总分,且不受折算系数影响。
func TestExportAthletesXLSXGuest(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.seedScoredResults(t, ids)
client, _ := env.client()
status, header, body := env.getRaw(t, client, "/api/export/athletes.xlsx")
if status != http.StatusOK {
t.Fatalf("guest 下载个人得分明细应 200,得到 %d %s", status, body)
}
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("个人积分总明细.xlsx")
if got := header.Get("Content-Disposition"); got != wantCD {
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
}
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
want := []string{"个人积分总明细", "第一分公司个人得分明细", "第二分公司个人得分明细"}
if got := f.GetSheetList(); !equalSheetList(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))
}
if got := masterCell(master, 4, 1); got != "张伟" {
t.Errorf("首行运动员 = %q,期望 张伟", got)
}
// 跨端点:明细合计 == GET /api/standings/athletes 的总分。
assertAPIAthleteTotals(t, env, client, f, 10)
// 折算系数只影响单位总分:个人明细与个人积分榜都必须原样不动。
env.exec(t, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
_, _, body2 := env.getRaw(t, client, "/api/export/athletes.xlsx")
f2 := openWorkbook(t, body2)
defer func() { _ = f2.Close() }()
rows, err := f2.GetRows("第一分公司个人得分明细")
if err != nil {
t.Fatalf("读取分表: %v", err)
}
if got := masterCell(rows, 4, 6); got != "10" {
t.Errorf("系数 0.5 时个人明细得分 = %q,期望 10", got)
}
assertAPIAthleteTotals(t, env, client, f2, 10)
}
// assertAPIAthleteTotals 比对 xlsx 里张伟的得分合计与 /api/standings/athletes 的总分。
func assertAPIAthleteTotals(t *testing.T, env *testEnv, client *http.Client, f *excelize.File, want float64) {
t.Helper()
rows, err := f.GetRows("个人积分总明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
var sum float64
var seen bool
for r := range rows {
if masterCell(rows, r, 1) != "张伟" {
continue
}
seen = true
v, err := strconv.ParseFloat(strings.TrimSpace(masterCell(rows, r, 7)), 64)
if err != nil {
t.Fatalf("解析明细得分 %q: %v", masterCell(rows, r, 7), err)
}
sum += v
}
if !seen {
t.Fatalf("个人明细里没有张伟的行")
}
if sum != want {
t.Errorf("张伟明细得分合计 = %v,期望 %v", sum, want)
}
status, _, body := env.getRaw(t, client, "/api/standings/athletes")
if status != http.StatusOK {
t.Fatalf("个人积分榜应 200,得到 %d %s", status, body)
}
var envl envelope
if err := json.Unmarshal(body, &envl); err != nil {
t.Fatalf("解析个人积分榜: %v (%s)", err, body)
}
list := decodeData[[]athleteStandingRow](t, envl)
for _, row := range list {
if row.AthleteName != "张伟" {
continue
}
if row.TotalPoints != sum {
t.Errorf("个人积分榜张伟总分 = %v,明细合计 = %v", row.TotalPoints, sum)
}
return
}
t.Fatalf("个人积分榜上没有张伟: %+v", list)
}