feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名

口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 12:02:00 +08:00
1 parent 592e47aef7
commit 6bd7bd1627
47 files changed
+1770 -153

No files matched your search

+160 -6
View File
@@ -191,7 +191,7 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
})
}
// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。
// TestAggregateChannel 覆盖 aggregate 通道:先按主体取平均、再排名。
func TestAggregateChannel(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
@@ -206,8 +206,8 @@ func TestAggregateChannel(t *testing.T) {
wantA float64
wantB float64
}{
{"求和后升序", CompareAsc, 15, 10},
{"求和后降序", CompareDesc, 10, 15},
{"平均后升序", CompareAsc, 15, 10},
{"平均后降序", CompareDesc, 10, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -227,10 +227,11 @@ func TestAggregateChannel(t *testing.T) {
})
}
t.Run("汇总后同分并列", func(t *testing.T) {
t.Run("平均后同分并列顺延", func(t *testing.T) {
// 1001 两人平均 (2+6)/2 = 4,与 1002 的 4 并列。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 1, athleteID: 1, teamID: 1001, value: 2},
{entryID: 2, athleteID: 2, teamID: 1001, value: 6},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
es := mustScore(t, EventScoreInput{
@@ -243,9 +244,162 @@ func TestAggregateChannel(t *testing.T) {
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) {
t.Fatalf("同分并列 = %v,期望各 15", got)
}
for _, rr := range es.Ranked {
if rr.Scope != ScopeTeam || rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
})
}
// TestAggregateAvgMode 覆盖聚合通道的平均值分母口径:
// valid 只算有有效成绩的人,all 把没成绩的人按 0 计入(分母 = 报名人数)。
func TestAggregateAvgMode(t *testing.T) {
// 1001 一人有成绩(10)、一人没成绩;1002 两人各 4 / 6。
// valid:1001 平均 10,1002 平均 5 → 1002 第一;
// all:1001 平均 10/2 = 5,与 1002 并列 → 各得第一名。
noValueBuild := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
})
}
// 1001 的第二个报名项连成绩行都没有(现场最常见的「没录成绩」):
// valid:1001 平均 10,1002 平均 6 → 1002 第一;all:1001 平均 10/2 = 5 → 1001 第一。
missingBuild := func() ([]Entry, []Result) {
return []Entry{
{ID: 1, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 1, AthleteName: "甲"},
{ID: 2, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 2, AthleteName: "乙"},
{ID: 3, EventID: 1, CategoryID: 1, TeamID: 1002, AthleteID: 3, AthleteName: "丙"},
}, []Result{
{ID: 100, EventID: 1, EntryID: 1, ResultValue: ptr(10), State: ResultStatePublished},
{ID: 300, EventID: 1, EntryID: 3, ResultValue: ptr(6), State: ResultStatePublished},
}
}
cases := []struct {
name string
build func() ([]Entry, []Result)
mode AvgMode
firstID int64
want1001 float64
want1002 float64
}{
{"只算有成绩的人", noValueBuild, AvgModeValid, 1002, 10, 15},
{"没成绩的按 0 计入", noValueBuild, AvgModeAll, 0, 15, 15},
{"未录成绩也算分母(valid)", missingBuild, AvgModeValid, 1002, 10, 15},
{"未录成绩也算分母(all)", missingBuild, AvgModeAll, 1001, 15, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := tc.build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], tc.want1001) || !nearlyEqual(got[1002], tc.want1002) {
t.Fatalf("平均口径 %s 得分 = %v,期望 1001:%v / 1002:%v", tc.mode, got, tc.want1001, tc.want1002)
}
if tc.firstID != 0 {
for _, rr := range es.Ranked {
if rr.TeamID == tc.firstID && rr.Rank != 1 {
t.Fatalf("单位 %d 名次 = %d,期望第 1 名", tc.firstID, rr.Rank)
}
}
}
})
}
t.Run("全队都没有有效成绩时不参与团体排名", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, noValue: true},
{entryID: 2, athleteID: 2, teamID: 1001, status: ResultStatusDNS},
{entryID: 3, athleteID: 3, teamID: 1002, value: 6},
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
})
ev := teamEvent(1)
ev.AvgMode = AvgModeValid
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if _, ok := got[1001]; ok {
t.Fatalf("无有效成绩的单位不应得分,实际 %v", got)
}
if !nearlyEqual(got[1002], 15) {
t.Fatalf("1002 得分 = %v,期望 15(顺延后独占第一)", got[1002])
}
for _, rr := range es.Ranked {
if rr.TeamID == 1001 {
t.Fatalf("无有效成绩的单位不应上榜:%+v", rr)
}
}
})
}
// TestAggregateChannelCountToTeam 覆盖「个人+团体」双通道:
// 个人通道的流水只记个人(count_to_team = 0),团体聚合通道的流水进单位总分。
func TestAggregateChannelCountToTeam(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1001, value: 14},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
notTeam := false
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1, CountToTeam: &notTeam},
teamAggregateChannel(2),
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var personal, teamLedger []LedgerEntry
for _, l := range es.Ledger {
if l.CountToTeam {
teamLedger = append(teamLedger, l)
} else {
personal = append(personal, l)
}
}
if len(personal) != 3 {
t.Fatalf("个人通道流水 = %d 条,期望 3 条", len(personal))
}
for _, l := range personal {
if l.AthleteID == 0 {
t.Fatalf("个人通道流水应记在个人头上:%+v", l)
}
}
// 个人通道的 10/8/6 分不进单位总分;团体通道各队平均 13 / 13 → 并列各 15。
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1001].Total, 15) || !nearlyEqual(sums[1002].Total, 15) {
t.Fatalf("单位总分 = %+v,期望各 15(个人通道不计入)", sums)
}
if !nearlyEqual(sums[1001].Individual, 0) {
t.Fatalf("不计入单位总分的个人流水被算进 Individual:%+v", sums[1001])
}
// 个人榜照常算上个人通道的分:12/13/14 米按升序拿 10/8/6。
athletePoints := SumByAthlete(es.Ledger)
if !nearlyEqual(athletePoints[1], 10) || !nearlyEqual(athletePoints[3], 8) || !nearlyEqual(athletePoints[2], 6) {
t.Fatalf("个人榜 = %v,期望 1:10 / 3:8 / 2:6", athletePoints)
}
if len(teamLedger) != 2 {
t.Fatalf("团体通道流水 = %d 条,期望 2 条", len(teamLedger))
}
}
// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。
func TestStatusExclusions(t *testing.T) {
cases := []struct {