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

+93
View File
@@ -363,6 +363,90 @@ func TestApplyNilChangeSet(t *testing.T) {
}
}
// 「个人+团体」默认模板的落库 / 导出 / 差异比对:
// 个人通道 countToTeam=false(只记个人),团体通道缺省 true(进单位总分)。
func TestApplyIndividualTeamTemplateCountToTeam(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier)
VALUES (1, 'EVT_BOTH', '团体引体向上', 'individual_team', 'count', 'desc', 1, 1)`); err != nil {
t.Fatalf("插入双计项目: %v", err)
}
next, err := Parse([]byte(individualTeamPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
rows, err := db.Query(`SELECT c.scope, c.basis, c.count_to_team FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_BOTH' ORDER BY c.sort_order, c.id`)
if err != nil {
t.Fatalf("查询通道: %v", err)
}
defer func() { _ = rows.Close() }()
type want struct {
scope, basis string
countToTeam int
}
wants := []want{{"athlete", "result", 0}, {"team", "aggregate", 1}}
i := 0
for rows.Next() {
var scope, basis string
var countToTeam int
if err := rows.Scan(&scope, &basis, &countToTeam); err != nil {
t.Fatalf("扫描通道: %v", err)
}
if i >= len(wants) || scope != wants[i].scope || basis != wants[i].basis || countToTeam != wants[i].countToTeam {
t.Fatalf("第 %d 条通道 = %s/%s/%d,期望 %+v", i, scope, basis, countToTeam, wants[i])
}
i++
}
if i != len(wants) {
t.Fatalf("EVT_BOTH 通道数 = %d,期望 %d", i, len(wants))
}
// 导出:只有显式关闭的通道写 false,缺省的团体通道不带该字段。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
var chans []Channel
for _, ov := range exported.EventOverrides {
if ov.EventCode == "EVT_BOTH" && ov.Channels != nil {
chans = *ov.Channels
}
}
if len(chans) != 2 {
t.Fatalf("导出通道数 = %d,期望 2(%+v)", len(chans), chans)
}
if chans[0].CountToTeam == nil || chans[0].CountsToTeam() {
t.Errorf("个人通道应导出 countToTeam: false,实际 %+v", chans[0].CountToTeam)
}
if chans[1].CountToTeam != nil || !chans[1].CountsToTeam() {
t.Errorf("团体通道应保持缺省 true,实际 %+v", chans[1].CountToTeam)
}
// 导出结果重新导入必须无差异:*bool 与缺省值不能凭空造出差异。
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。
func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) {
db := openEventDB(t)
@@ -421,3 +505,12 @@ func queryString(t *testing.T, db *sql.DB, query string) string {
}
return v
}
func mustMarshal(t *testing.T, pack *RulePack) []byte {
t.Helper()
raw, err := Marshal(pack)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
return raw
}