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

+3 -2
View File
@@ -85,8 +85,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i); err != nil {
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
+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
}
+6 -1
View File
@@ -32,14 +32,19 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// countToTeam 缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
out := domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
}
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
}
return out
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
+6 -1
View File
@@ -239,12 +239,17 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
return out
}
// channelsEqual 逐字段比较目标通道与库中通道。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile {
return false
}
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
}
+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)
}
}
+15 -9
View File
@@ -243,7 +243,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
@@ -253,24 +253,30 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
if !ok {
continue
}
events[idx].Channels = append(events[idx].Channels, Channel{
ch := Channel{
Scope: domain.Scope(scope),
Basis: domain.Basis(basis),
Profile: code,
})
}
// 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
}
events[idx].Channels = append(events[idx].Channels, ch)
}
if err := chRows.Err(); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
+7 -1
View File
@@ -96,13 +96,19 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 固定按同一主体成绩之和汇总;汇总口径不可配置(库里没有对应列)。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`