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:
1 parent
592e47aef7
commit
6bd7bd1627
47 files changed
+1770
-153
No files matched your search
@@ -192,9 +192,16 @@ func testTeams(ids ...int64) []Team {
|
||||
}
|
||||
|
||||
func ledgerFor(teamID int64, points float64) LedgerEntry {
|
||||
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points}
|
||||
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points, CountToTeam: true}
|
||||
}
|
||||
|
||||
func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry {
|
||||
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points}
|
||||
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points, CountToTeam: true}
|
||||
}
|
||||
|
||||
// ledgerForPersonal 是「不计入单位总分」的流水:只记在个人头上(如「个人+团体」项目的个人通道)。
|
||||
func ledgerForPersonal(teamID, athleteID int64, points float64) LedgerEntry {
|
||||
e := ledgerForAthlete(teamID, athleteID, points)
|
||||
e.CountToTeam = false
|
||||
return e
|
||||
}
|
||||
@@ -9,13 +9,14 @@ type TeamPoints struct {
|
||||
|
||||
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
|
||||
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
|
||||
// count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。
|
||||
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
out := make(map[int64]TeamPoints)
|
||||
for _, e := range entries {
|
||||
if e.Voided {
|
||||
if e.Voided || !e.CountToTeam {
|
||||
continue
|
||||
}
|
||||
p := out[e.TeamID]
|
||||
@@ -31,6 +32,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
}
|
||||
|
||||
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
|
||||
// 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。
|
||||
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
|
||||
out := make(map[int64]float64)
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -33,3 +33,18 @@ func TestSumByAthlete(t *testing.T) {
|
||||
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的个人通道:分只记个人,不进单位总分。
|
||||
func TestSumByTeamSkipsPersonalOnly(t *testing.T) {
|
||||
ledger := []LedgerEntry{
|
||||
ledgerFor(1, 10),
|
||||
ledgerForPersonal(1, 101, 8),
|
||||
}
|
||||
got := SumByTeam(ledger, 1)
|
||||
if !nearlyEqual(got[1].Total, 10) || !nearlyEqual(got[1].Individual, 0) {
|
||||
t.Fatalf("单位1汇总 = %+v,期望总分 10 且个人分不进单位", got[1])
|
||||
}
|
||||
if !nearlyEqual(SumByAthlete(ledger)[101], 8) {
|
||||
t.Fatalf("个人榜应保留该流水:%v", SumByAthlete(ledger))
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
|
||||
|
||||
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
|
||||
in.Ledger = append(in.Ledger, LedgerEntry{
|
||||
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
|
||||
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3, CountToTeam: true,
|
||||
})
|
||||
|
||||
after := BuildTeamStandings(in)
|
||||
|
||||
@@ -148,14 +148,15 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
}
|
||||
for _, s := range e.resolvedShares(in.Options.UnionAttribution) {
|
||||
out = append(out, LedgerEntry{
|
||||
TeamID: s.TeamID,
|
||||
AthleteID: athleteID,
|
||||
EventID: in.Event.ID,
|
||||
CategoryID: in.Category.ID,
|
||||
EntryID: e.ID,
|
||||
SourceType: SourceRecordBonus,
|
||||
SourceID: e.ID,
|
||||
Points: b.Points * s.Weight,
|
||||
TeamID: s.TeamID,
|
||||
AthleteID: athleteID,
|
||||
EventID: in.Event.ID,
|
||||
CategoryID: in.Category.ID,
|
||||
EntryID: e.ID,
|
||||
SourceType: SourceRecordBonus,
|
||||
SourceID: e.ID,
|
||||
Points: b.Points * s.Weight,
|
||||
CountToTeam: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,15 +156,23 @@ type candidate struct {
|
||||
rankIn int // manual 通道录入的名次
|
||||
}
|
||||
|
||||
// entryMatches 判断报名项是否属于本次计分的小项与组别。
|
||||
func entryMatches(in EventScoreInput, e Entry) bool {
|
||||
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
|
||||
return false
|
||||
}
|
||||
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// collectCandidates 过滤出本通道真正参与排名的报名项:
|
||||
// 只取已发布、状态正常、且带有本通道所需数据的成绩。
|
||||
// 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。
|
||||
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
|
||||
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
}
|
||||
if ch.Basis == BasisAggregate && in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
}
|
||||
|
||||
byEntry := make(map[int64]Result, len(in.Results))
|
||||
for _, r := range in.Results {
|
||||
@@ -173,10 +181,7 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
|
||||
|
||||
out := make([]candidate, 0, len(in.Entries))
|
||||
for _, e := range in.Entries {
|
||||
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
|
||||
continue
|
||||
}
|
||||
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
||||
if !entryMatches(in, e) {
|
||||
continue
|
||||
}
|
||||
if !e.Status.rankable() {
|
||||
@@ -200,7 +205,7 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
|
||||
continue
|
||||
}
|
||||
c.rankIn = r.Rank
|
||||
default: // result / aggregate 都按成绩值排序
|
||||
default: // result 按成绩值排序
|
||||
if r.ResultValue == nil {
|
||||
continue
|
||||
}
|
||||
@@ -362,41 +367,72 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
return rr
|
||||
}
|
||||
|
||||
// scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
|
||||
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均,
|
||||
// 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。
|
||||
// 平均值分母由项目的 avg_mode 决定:
|
||||
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
|
||||
// - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。
|
||||
// 没有报名项的主体不会成桶,自然不参与。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
|
||||
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
cands, err := collectCandidates(in, ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
}
|
||||
|
||||
byEntry := make(map[int64]Result, len(in.Results))
|
||||
for _, r := range in.Results {
|
||||
byEntry[r.EntryID] = r
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
subject int64
|
||||
entry Entry
|
||||
total float64
|
||||
sum float64 // 有效成绩之和
|
||||
valid int // 有有效成绩的人数(valid 口径的分母)
|
||||
roster int // 报名人数(all 口径的分母)
|
||||
}
|
||||
order := make([]int64, 0)
|
||||
buckets := make(map[int64]*bucket)
|
||||
for _, c := range cands {
|
||||
subject := c.entry.TeamID
|
||||
for _, e := range in.Entries {
|
||||
if !entryMatches(in, e) || !e.Status.rankable() {
|
||||
continue
|
||||
}
|
||||
subject := e.TeamID
|
||||
if ch.Scope == ScopeAthlete {
|
||||
subject = c.entry.AthleteID
|
||||
subject = e.AthleteID
|
||||
}
|
||||
if subject == 0 {
|
||||
continue
|
||||
}
|
||||
b, ok := buckets[subject]
|
||||
if !ok {
|
||||
b = &bucket{subject: subject, entry: c.entry}
|
||||
b = &bucket{subject: subject, entry: e}
|
||||
buckets[subject] = b
|
||||
order = append(order, subject)
|
||||
}
|
||||
b.total += c.value
|
||||
b.roster++
|
||||
r, found := byEntry[e.ID]
|
||||
if v, ok := validResultValue(r, found, in.Options); ok {
|
||||
b.sum += v
|
||||
b.valid++
|
||||
}
|
||||
}
|
||||
|
||||
agg := make([]candidate, 0, len(order))
|
||||
for _, id := range order {
|
||||
b := buckets[id]
|
||||
c := candidate{entry: b.entry, subject: b.subject, value: b.total}
|
||||
var value float64
|
||||
if in.Event.avgMode() == AvgModeAll {
|
||||
// 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。
|
||||
value = b.sum / float64(b.roster)
|
||||
} else {
|
||||
if b.valid == 0 {
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分。
|
||||
continue
|
||||
}
|
||||
value = b.sum / float64(b.valid)
|
||||
}
|
||||
c := candidate{entry: b.entry, subject: b.subject, value: value}
|
||||
c.entry.ID = 0 // 聚合结果不对应单条报名项
|
||||
agg = append(agg, c)
|
||||
}
|
||||
@@ -456,8 +492,24 @@ func nearlyEqual(a, b float64) bool {
|
||||
return math.Abs(a-b) <= rankEpsilon
|
||||
}
|
||||
|
||||
// validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、
|
||||
// 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。
|
||||
func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
|
||||
if !found {
|
||||
return 0, false
|
||||
}
|
||||
if r.State == ResultStateDraft && !opts.IncludeDrafts {
|
||||
return 0, false
|
||||
}
|
||||
if !r.Status.rankable() || r.ResultValue == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *r.ResultValue, true
|
||||
}
|
||||
|
||||
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位,
|
||||
// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。
|
||||
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
|
||||
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
|
||||
var out []LedgerEntry
|
||||
for _, rr := range ranked {
|
||||
@@ -481,16 +533,17 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
continue
|
||||
}
|
||||
out = append(out, LedgerEntry{
|
||||
TeamID: s.TeamID,
|
||||
AthleteID: athleteID,
|
||||
EventID: in.Event.ID,
|
||||
CategoryID: in.Category.ID,
|
||||
EntryID: rr.EntryID,
|
||||
ResultID: rr.ResultID,
|
||||
ChannelID: ch.ID,
|
||||
SourceType: SourceResult,
|
||||
SourceID: sourceID,
|
||||
Points: pts,
|
||||
TeamID: s.TeamID,
|
||||
AthleteID: athleteID,
|
||||
EventID: in.Event.ID,
|
||||
CategoryID: in.Category.ID,
|
||||
EntryID: rr.EntryID,
|
||||
ResultID: rr.ResultID,
|
||||
ChannelID: ch.ID,
|
||||
SourceType: SourceResult,
|
||||
SourceID: sourceID,
|
||||
Points: pts,
|
||||
CountToTeam: ch.countToTeam(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ¬Team},
|
||||
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 {
|
||||
|
||||
@@ -360,3 +360,25 @@ func TestAthleteStandingsTieBreakConfig(t *testing.T) {
|
||||
t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的双计:个人通道的分不进单位总分,只有团体通道的分参与单位排名。
|
||||
func TestTeamStandingsIgnoresPersonalOnlyLedger(t *testing.T) {
|
||||
ledger := []LedgerEntry{
|
||||
ledgerForPersonal(1, 101, 10), // 个人通道:单位1 不进分
|
||||
ledgerFor(2, 8), // 团体通道
|
||||
}
|
||||
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
|
||||
if rows[0].TeamID != 2 {
|
||||
t.Fatalf("榜首 = 单位 %d,期望 2(个人通道不计入单位总分)", rows[0].TeamID)
|
||||
}
|
||||
if !nearlyEqual(rows[1].Points, 0) || !nearlyEqual(rows[1].IndividualPoints, 0) {
|
||||
t.Fatalf("单位1 = %+v,期望 0 分", rows[1])
|
||||
}
|
||||
|
||||
athlete := BuildAthleteStandings(StandingsInput{
|
||||
Teams: testTeams(1, 2), Athletes: []Athlete{{ID: 101, Name: "张三", TeamID: 1}}, Ledger: ledger,
|
||||
})
|
||||
if len(athlete) != 1 || !nearlyEqual(athlete[0].Points, 10) {
|
||||
t.Fatalf("个人榜 = %+v,期望仍保留个人通道的 10 分", athlete)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,16 @@ const (
|
||||
ResultModeManual ResultMode = "manual"
|
||||
)
|
||||
|
||||
// AvgMode 团体成绩取平均时的分母口径,只对 basis=aggregate 的通道有效。
|
||||
type AvgMode string
|
||||
|
||||
const (
|
||||
// AvgModeValid 只算有有效成绩的人:某单位一个人都没成绩就不参与团体排名。
|
||||
AvgModeValid AvgMode = "valid"
|
||||
// AvgModeAll 谁都算,没成绩的人按 0 计入,分母 = 该单位在该组别的报名人数。
|
||||
AvgModeAll AvgMode = "all"
|
||||
)
|
||||
|
||||
// ResultType 成绩的物理量类型,决定比较与展示方式。
|
||||
type ResultType string
|
||||
|
||||
@@ -209,6 +219,8 @@ type Event struct {
|
||||
Venue string `json:"venue,omitempty"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
// AvgMode 团体成绩(basis=aggregate 通道)取平均时的分母口径;空值按 valid 处理。
|
||||
AvgMode AvgMode `json:"avgMode"`
|
||||
}
|
||||
|
||||
// EventCategory 组别;名次在组别内有效,组别权重乘在成绩分上。
|
||||
@@ -225,8 +237,7 @@ type EventCategory struct {
|
||||
|
||||
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
|
||||
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
|
||||
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
|
||||
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
|
||||
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
|
||||
type ScoreChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
@@ -234,6 +245,9 @@ type ScoreChannel struct {
|
||||
Basis Basis `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
|
||||
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
|
||||
CountToTeam *bool `json:"countToTeam,omitempty"`
|
||||
}
|
||||
|
||||
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
@@ -310,11 +324,13 @@ type LedgerEntry struct {
|
||||
SourceType SourceType `json:"sourceType"`
|
||||
SourceID int64 `json:"sourceId"`
|
||||
Points float64 `json:"points"`
|
||||
Voided bool `json:"voided"`
|
||||
VoidedBy string `json:"voidedBy,omitempty"`
|
||||
VoidReason string `json:"voidReason,omitempty"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
// CountToTeam 为 false 时该流水只记在个人头上,不计入单位总分(库中默认 1)。
|
||||
CountToTeam bool `json:"countToTeam"`
|
||||
Voided bool `json:"voided"`
|
||||
VoidedBy string `json:"voidedBy,omitempty"`
|
||||
VoidReason string `json:"voidReason,omitempty"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// StandingsRow 总积分榜的一行。
|
||||
@@ -429,6 +445,19 @@ func (e Event) multiplier() float64 {
|
||||
return e.Multiplier
|
||||
}
|
||||
|
||||
// avgMode 未配置或配错时按 valid 处理(与库里的默认值一致)。
|
||||
func (e Event) avgMode() AvgMode {
|
||||
if e.AvgMode == AvgModeAll {
|
||||
return AvgModeAll
|
||||
}
|
||||
return AvgModeValid
|
||||
}
|
||||
|
||||
// countToTeam 未配置时按 true 处理:只有显式关闭的通道才不进单位总分。
|
||||
func (c ScoreChannel) countToTeam() bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
func (c EventCategory) multiplier() float64 {
|
||||
if c.Multiplier <= 0 {
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user