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