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:
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)
|
||||
}
|
||||
Reference in new issue
Block a user