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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 按模板编码建索引,便于通道引用解析。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user