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

+14 -3
View File
@@ -260,6 +260,7 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
const individualTeamPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 个人+团体模板 }
@@ -282,8 +283,8 @@ profiles:
channelTemplates:
- match: { eventType: individual_team }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
`
func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
@@ -313,7 +314,17 @@ func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" {
t.Errorf("个人通道 = %+v", got[0])
}
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisManual || got[1].Profile != "team_top6" {
if got[0].CountsToTeam() {
t.Error("个人通道不应计入单位总分")
}
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisAggregate || got[1].Profile != "team_top6" {
t.Errorf("团体通道 = %+v", got[1])
}
if !got[1].CountsToTeam() {
t.Error("团体通道应计入单位总分")
}
// 解析缺省:没写 countToTeam 的通道一律计入单位总分,且不写成显式值。
if got[1].CountToTeam != nil {
t.Errorf("团体通道 countToTeam 应保持缺省(nil),实际 %v", *got[1].CountToTeam)
}
}