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
+1732
-115
No files matched your search
+5
-1
@@ -266,6 +266,8 @@
|
||||
4. 删除大项 / 小项会二次确认。
|
||||
|
||||
> 「个人+团体」用于团体与个人赛事合并的项目:会同时按个人通道与团体通道记分,具体通道见 10.4「计分通道」。
|
||||
> 个人通道的分**只记在个人榜**,不计入单位总分;单位总分只算团体通道——把本单位成员在该组别的成绩取平均后排名再给分。
|
||||
> 平均值分母(「平均值口径」)在录入页选择:选「只算有效成绩」时,一个人都没成绩的单位不参与团体排名、不得分;选「全部报名人员都计入」时,没成绩的人按 0 计入,分母是该单位在该组别的报名人数。
|
||||
|
||||
### 10.3 组别
|
||||
|
||||
@@ -281,7 +283,9 @@
|
||||
1. 先选中一个小项;
|
||||
2. 点「新增通道」逐条配置,同一「名次范围 + 名次来源」只能有一个通道;
|
||||
3. 点「保存全部」整体替换该小项的通道配置;
|
||||
4. 标记为「不计分」的小项,通道可以保存但不参与积分计算。
|
||||
4. 标记为「不计分」的小项,通道可以保存但不参与积分计算;
|
||||
5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**取平均**后排名,并列按积分模板的并列口径处理(默认顺延);平均值分母取录入页选的「平均值口径」;
|
||||
6. 每条通道有一个「计入单位总分」开关,默认打开。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人。
|
||||
|
||||
### 10.5 积分模板
|
||||
|
||||
|
||||
@@ -192,9 +192,16 @@ func testTeams(ids ...int64) []Team {
|
||||
}
|
||||
|
||||
func ledgerFor(teamID int64, points float64) LedgerEntry {
|
||||
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points}
|
||||
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points, CountToTeam: true}
|
||||
}
|
||||
|
||||
func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry {
|
||||
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points}
|
||||
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points, CountToTeam: true}
|
||||
}
|
||||
|
||||
// ledgerForPersonal 是「不计入单位总分」的流水:只记在个人头上(如「个人+团体」项目的个人通道)。
|
||||
func ledgerForPersonal(teamID, athleteID int64, points float64) LedgerEntry {
|
||||
e := ledgerForAthlete(teamID, athleteID, points)
|
||||
e.CountToTeam = false
|
||||
return e
|
||||
}
|
||||
@@ -9,13 +9,14 @@ type TeamPoints struct {
|
||||
|
||||
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
|
||||
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
|
||||
// count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。
|
||||
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
out := make(map[int64]TeamPoints)
|
||||
for _, e := range entries {
|
||||
if e.Voided {
|
||||
if e.Voided || !e.CountToTeam {
|
||||
continue
|
||||
}
|
||||
p := out[e.TeamID]
|
||||
@@ -31,6 +32,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
}
|
||||
|
||||
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
|
||||
// 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。
|
||||
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
|
||||
out := make(map[int64]float64)
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -33,3 +33,18 @@ func TestSumByAthlete(t *testing.T) {
|
||||
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的个人通道:分只记个人,不进单位总分。
|
||||
func TestSumByTeamSkipsPersonalOnly(t *testing.T) {
|
||||
ledger := []LedgerEntry{
|
||||
ledgerFor(1, 10),
|
||||
ledgerForPersonal(1, 101, 8),
|
||||
}
|
||||
got := SumByTeam(ledger, 1)
|
||||
if !nearlyEqual(got[1].Total, 10) || !nearlyEqual(got[1].Individual, 0) {
|
||||
t.Fatalf("单位1汇总 = %+v,期望总分 10 且个人分不进单位", got[1])
|
||||
}
|
||||
if !nearlyEqual(SumByAthlete(ledger)[101], 8) {
|
||||
t.Fatalf("个人榜应保留该流水:%v", SumByAthlete(ledger))
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
|
||||
|
||||
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
|
||||
in.Ledger = append(in.Ledger, LedgerEntry{
|
||||
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
|
||||
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3, CountToTeam: true,
|
||||
})
|
||||
|
||||
after := BuildTeamStandings(in)
|
||||
|
||||
@@ -156,6 +156,7 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
SourceType: SourceRecordBonus,
|
||||
SourceID: e.ID,
|
||||
Points: b.Points * s.Weight,
|
||||
CountToTeam: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -491,6 +543,7 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
SourceType: SourceResult,
|
||||
SourceID: sourceID,
|
||||
Points: pts,
|
||||
CountToTeam: ch.countToTeam(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。
|
||||
// TestAggregateChannel 覆盖 aggregate 通道:先按主体取平均、再排名。
|
||||
func TestAggregateChannel(t *testing.T) {
|
||||
build := func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
@@ -206,8 +206,8 @@ func TestAggregateChannel(t *testing.T) {
|
||||
wantA float64
|
||||
wantB float64
|
||||
}{
|
||||
{"求和后升序", CompareAsc, 15, 10},
|
||||
{"求和后降序", CompareDesc, 10, 15},
|
||||
{"平均后升序", CompareAsc, 15, 10},
|
||||
{"平均后降序", CompareDesc, 10, 15},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -227,10 +227,11 @@ func TestAggregateChannel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("汇总后同分并列", func(t *testing.T) {
|
||||
t.Run("平均后同分并列顺延", func(t *testing.T) {
|
||||
// 1001 两人平均 (2+6)/2 = 4,与 1002 的 4 并列。
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 2},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 6},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
|
||||
})
|
||||
es := mustScore(t, EventScoreInput{
|
||||
@@ -243,9 +244,162 @@ func TestAggregateChannel(t *testing.T) {
|
||||
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) {
|
||||
t.Fatalf("同分并列 = %v,期望各 15", got)
|
||||
}
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.Scope != ScopeTeam || rr.Rank != 1 {
|
||||
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAggregateAvgMode 覆盖聚合通道的平均值分母口径:
|
||||
// valid 只算有有效成绩的人,all 把没成绩的人按 0 计入(分母 = 报名人数)。
|
||||
func TestAggregateAvgMode(t *testing.T) {
|
||||
// 1001 一人有成绩(10)、一人没成绩;1002 两人各 4 / 6。
|
||||
// valid:1001 平均 10,1002 平均 5 → 1002 第一;
|
||||
// all:1001 平均 10/2 = 5,与 1002 并列 → 各得第一名。
|
||||
noValueBuild := func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
|
||||
})
|
||||
}
|
||||
// 1001 的第二个报名项连成绩行都没有(现场最常见的「没录成绩」):
|
||||
// valid:1001 平均 10,1002 平均 6 → 1002 第一;all:1001 平均 10/2 = 5 → 1001 第一。
|
||||
missingBuild := func() ([]Entry, []Result) {
|
||||
return []Entry{
|
||||
{ID: 1, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 1, AthleteName: "甲"},
|
||||
{ID: 2, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 2, AthleteName: "乙"},
|
||||
{ID: 3, EventID: 1, CategoryID: 1, TeamID: 1002, AthleteID: 3, AthleteName: "丙"},
|
||||
}, []Result{
|
||||
{ID: 100, EventID: 1, EntryID: 1, ResultValue: ptr(10), State: ResultStatePublished},
|
||||
{ID: 300, EventID: 1, EntryID: 3, ResultValue: ptr(6), State: ResultStatePublished},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func() ([]Entry, []Result)
|
||||
mode AvgMode
|
||||
firstID int64
|
||||
want1001 float64
|
||||
want1002 float64
|
||||
}{
|
||||
{"只算有成绩的人", noValueBuild, AvgModeValid, 1002, 10, 15},
|
||||
{"没成绩的按 0 计入", noValueBuild, AvgModeAll, 0, 15, 15},
|
||||
{"未录成绩也算分母(valid)", missingBuild, AvgModeValid, 1002, 10, 15},
|
||||
{"未录成绩也算分母(all)", missingBuild, AvgModeAll, 1001, 15, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entries, results := tc.build()
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = tc.mode
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamAggregateChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
got := pointsByTeam(es.Ledger)
|
||||
if !nearlyEqual(got[1001], tc.want1001) || !nearlyEqual(got[1002], tc.want1002) {
|
||||
t.Fatalf("平均口径 %s 得分 = %v,期望 1001:%v / 1002:%v", tc.mode, got, tc.want1001, tc.want1002)
|
||||
}
|
||||
if tc.firstID != 0 {
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.TeamID == tc.firstID && rr.Rank != 1 {
|
||||
t.Fatalf("单位 %d 名次 = %d,期望第 1 名", tc.firstID, rr.Rank)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("全队都没有有效成绩时不参与团体排名", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, noValue: true},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, status: ResultStatusDNS},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 6},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = AvgModeValid
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamAggregateChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
got := pointsByTeam(es.Ledger)
|
||||
if _, ok := got[1001]; ok {
|
||||
t.Fatalf("无有效成绩的单位不应得分,实际 %v", got)
|
||||
}
|
||||
if !nearlyEqual(got[1002], 15) {
|
||||
t.Fatalf("1002 得分 = %v,期望 15(顺延后独占第一)", got[1002])
|
||||
}
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.TeamID == 1001 {
|
||||
t.Fatalf("无有效成绩的单位不应上榜:%+v", rr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAggregateChannelCountToTeam 覆盖「个人+团体」双通道:
|
||||
// 个人通道的流水只记个人(count_to_team = 0),团体聚合通道的流水进单位总分。
|
||||
func TestAggregateChannelCountToTeam(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 14},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
|
||||
})
|
||||
notTeam := false
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1, CountToTeam: ¬Team},
|
||||
teamAggregateChannel(2),
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
|
||||
var personal, teamLedger []LedgerEntry
|
||||
for _, l := range es.Ledger {
|
||||
if l.CountToTeam {
|
||||
teamLedger = append(teamLedger, l)
|
||||
} else {
|
||||
personal = append(personal, l)
|
||||
}
|
||||
}
|
||||
if len(personal) != 3 {
|
||||
t.Fatalf("个人通道流水 = %d 条,期望 3 条", len(personal))
|
||||
}
|
||||
for _, l := range personal {
|
||||
if l.AthleteID == 0 {
|
||||
t.Fatalf("个人通道流水应记在个人头上:%+v", l)
|
||||
}
|
||||
}
|
||||
// 个人通道的 10/8/6 分不进单位总分;团体通道各队平均 13 / 13 → 并列各 15。
|
||||
sums := SumByTeam(es.Ledger, 1)
|
||||
if !nearlyEqual(sums[1001].Total, 15) || !nearlyEqual(sums[1002].Total, 15) {
|
||||
t.Fatalf("单位总分 = %+v,期望各 15(个人通道不计入)", sums)
|
||||
}
|
||||
if !nearlyEqual(sums[1001].Individual, 0) {
|
||||
t.Fatalf("不计入单位总分的个人流水被算进 Individual:%+v", sums[1001])
|
||||
}
|
||||
// 个人榜照常算上个人通道的分:12/13/14 米按升序拿 10/8/6。
|
||||
athletePoints := SumByAthlete(es.Ledger)
|
||||
if !nearlyEqual(athletePoints[1], 10) || !nearlyEqual(athletePoints[3], 8) || !nearlyEqual(athletePoints[2], 6) {
|
||||
t.Fatalf("个人榜 = %v,期望 1:10 / 3:8 / 2:6", athletePoints)
|
||||
}
|
||||
if len(teamLedger) != 2 {
|
||||
t.Fatalf("团体通道流水 = %d 条,期望 2 条", len(teamLedger))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。
|
||||
func TestStatusExclusions(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
@@ -360,3 +360,25 @@ func TestAthleteStandingsTieBreakConfig(t *testing.T) {
|
||||
t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的双计:个人通道的分不进单位总分,只有团体通道的分参与单位排名。
|
||||
func TestTeamStandingsIgnoresPersonalOnlyLedger(t *testing.T) {
|
||||
ledger := []LedgerEntry{
|
||||
ledgerForPersonal(1, 101, 10), // 个人通道:单位1 不进分
|
||||
ledgerFor(2, 8), // 团体通道
|
||||
}
|
||||
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
|
||||
if rows[0].TeamID != 2 {
|
||||
t.Fatalf("榜首 = 单位 %d,期望 2(个人通道不计入单位总分)", rows[0].TeamID)
|
||||
}
|
||||
if !nearlyEqual(rows[1].Points, 0) || !nearlyEqual(rows[1].IndividualPoints, 0) {
|
||||
t.Fatalf("单位1 = %+v,期望 0 分", rows[1])
|
||||
}
|
||||
|
||||
athlete := BuildAthleteStandings(StandingsInput{
|
||||
Teams: testTeams(1, 2), Athletes: []Athlete{{ID: 101, Name: "张三", TeamID: 1}}, Ledger: ledger,
|
||||
})
|
||||
if len(athlete) != 1 || !nearlyEqual(athlete[0].Points, 10) {
|
||||
t.Fatalf("个人榜 = %+v,期望仍保留个人通道的 10 分", athlete)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,16 @@ const (
|
||||
ResultModeManual ResultMode = "manual"
|
||||
)
|
||||
|
||||
// AvgMode 团体成绩取平均时的分母口径,只对 basis=aggregate 的通道有效。
|
||||
type AvgMode string
|
||||
|
||||
const (
|
||||
// AvgModeValid 只算有有效成绩的人:某单位一个人都没成绩就不参与团体排名。
|
||||
AvgModeValid AvgMode = "valid"
|
||||
// AvgModeAll 谁都算,没成绩的人按 0 计入,分母 = 该单位在该组别的报名人数。
|
||||
AvgModeAll AvgMode = "all"
|
||||
)
|
||||
|
||||
// ResultType 成绩的物理量类型,决定比较与展示方式。
|
||||
type ResultType string
|
||||
|
||||
@@ -209,6 +219,8 @@ type Event struct {
|
||||
Venue string `json:"venue,omitempty"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
// AvgMode 团体成绩(basis=aggregate 通道)取平均时的分母口径;空值按 valid 处理。
|
||||
AvgMode AvgMode `json:"avgMode"`
|
||||
}
|
||||
|
||||
// EventCategory 组别;名次在组别内有效,组别权重乘在成绩分上。
|
||||
@@ -225,8 +237,7 @@ type EventCategory struct {
|
||||
|
||||
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
|
||||
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
|
||||
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
|
||||
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
|
||||
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
|
||||
type ScoreChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
@@ -234,6 +245,9 @@ type ScoreChannel struct {
|
||||
Basis Basis `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
|
||||
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
|
||||
CountToTeam *bool `json:"countToTeam,omitempty"`
|
||||
}
|
||||
|
||||
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
@@ -310,6 +324,8 @@ type LedgerEntry struct {
|
||||
SourceType SourceType `json:"sourceType"`
|
||||
SourceID int64 `json:"sourceId"`
|
||||
Points float64 `json:"points"`
|
||||
// CountToTeam 为 false 时该流水只记在个人头上,不计入单位总分(库中默认 1)。
|
||||
CountToTeam bool `json:"countToTeam"`
|
||||
Voided bool `json:"voided"`
|
||||
VoidedBy string `json:"voidedBy,omitempty"`
|
||||
VoidReason string `json:"voidReason,omitempty"`
|
||||
@@ -429,6 +445,19 @@ func (e Event) multiplier() float64 {
|
||||
return e.Multiplier
|
||||
}
|
||||
|
||||
// avgMode 未配置或配错时按 valid 处理(与库里的默认值一致)。
|
||||
func (e Event) avgMode() AvgMode {
|
||||
if e.AvgMode == AvgModeAll {
|
||||
return AvgModeAll
|
||||
}
|
||||
return AvgModeValid
|
||||
}
|
||||
|
||||
// countToTeam 未配置时按 true 处理:只有显式关闭的通道才不进单位总分。
|
||||
func (c ScoreChannel) countToTeam() bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
func (c EventCategory) multiplier() float64 {
|
||||
if c.Multiplier <= 0 {
|
||||
return 1
|
||||
|
||||
@@ -23,6 +23,7 @@ type Event struct {
|
||||
Venue *string `json:"venue"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark *string `json:"remark"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
}
|
||||
|
||||
// EventFromDomain 把领域小项转换成线上结构。
|
||||
@@ -44,5 +45,6 @@ func EventFromDomain(e domain.Event) Event {
|
||||
Venue: nilIfEmpty(e.Venue),
|
||||
SortOrder: e.SortOrder,
|
||||
Remark: nilIfEmpty(e.Remark),
|
||||
AvgMode: string(e.AvgMode),
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
|
||||
COALESCE(entry_id, 0), COALESCE(result_id, 0),
|
||||
source_type, source_id, points, voided, COALESCE(void_reason, '')
|
||||
source_type, source_id, points, voided, COALESCE(void_reason, ''), count_to_team
|
||||
FROM score_ledger ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取积分流水: %w", err)
|
||||
@@ -256,13 +256,17 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
|
||||
var out []domain.LedgerEntry
|
||||
for rows.Next() {
|
||||
var e domain.LedgerEntry
|
||||
var voided int
|
||||
var (
|
||||
e domain.LedgerEntry
|
||||
voided int
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID,
|
||||
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil {
|
||||
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason, &countToTeam); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
|
||||
}
|
||||
e.Voided = voided != 0
|
||||
e.CountToTeam = countToTeam != 0
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -548,7 +552,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
LEFT JOIN event_categories c ON c.id = en.category_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
WHERE l.team_id = ? AND l.voided = 0
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
|
||||
|
||||
@@ -191,3 +191,35 @@ func TestTeamDetailTotalAppliesWeight(t *testing.T) {
|
||||
t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
|
||||
// 导出侧与 REST / WS 快照必须同口径。
|
||||
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// 单位 1 再加一条「只记个人」的流水:导出总分不得变化。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, count_to_team)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 7, 0)`); err != nil {
|
||||
t.Fatalf("写入个人通道流水: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Team {
|
||||
if row.TeamID != 1 {
|
||||
continue
|
||||
}
|
||||
if row.Points != 10 {
|
||||
t.Fatalf("单位1 总分 = %v,期望 10(不计入单位总分的流水应被跳过)", row.Points)
|
||||
}
|
||||
}
|
||||
// 个人榜照常算上这条流水:10 + 7 = 17。
|
||||
for _, row := range wb.Athlete {
|
||||
if row.AthleteID == 1 && row.Points != 17 {
|
||||
t.Fatalf("个人榜得分 = %v,期望 17", row.Points)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ type channelDTO struct {
|
||||
Basis string `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
|
||||
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
|
||||
CountToTeam *bool `json:"countToTeam"`
|
||||
}
|
||||
|
||||
type eventDTO struct {
|
||||
@@ -73,6 +76,7 @@ type eventDTO struct {
|
||||
Venue *string `json:"venue"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark *string `json:"remark"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 「个人+团体」的算分口径(REST 端到端):
|
||||
// - 个人通道按个人成绩排名,分只记在个人头上,不进单位总分;
|
||||
// - 团体通道把同单位成员成绩取平均后排名,这部分才进单位总分。
|
||||
func TestIndividualTeamDoubleCountStandings(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 换成「个人+团体」:成绩越大越好,团体成绩按成员平均。
|
||||
env.exec(t, `UPDATE events SET event_type = 'individual_team', result_type = 'count',
|
||||
compare = 'desc', avg_mode = 'valid' WHERE id = ?`, ids.event)
|
||||
|
||||
var individualProfile, teamProfile int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&individualProfile); err != nil {
|
||||
t.Fatalf("读取个人模板: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
|
||||
t.Fatalf("读取团体模板: %v", err)
|
||||
}
|
||||
|
||||
// 请求体里个人通道显式关闭 countToTeam,团体通道不写该字段(缺省计入)。
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
|
||||
map[string]any{"channels": []map[string]any{
|
||||
{"scope": "athlete", "basis": "result", "profileId": individualProfile, "sortOrder": 0, "countToTeam": false},
|
||||
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("保存通道应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/channels", ids.event), nil, true)
|
||||
channels := decodeData[[]map[string]any](t, env2)
|
||||
if len(channels) != 2 {
|
||||
t.Fatalf("通道数 = %d,期望 2", len(channels))
|
||||
}
|
||||
if got := channels[0]["countToTeam"]; got != false {
|
||||
t.Fatalf("个人通道 countToTeam = %v,期望 false", got)
|
||||
}
|
||||
if got := channels[1]["countToTeam"]; got != true {
|
||||
t.Fatalf("缺省通道 countToTeam = %v,期望 true", got)
|
||||
}
|
||||
|
||||
// 单位 A 一人 10,单位 B 一人 20:个人通道 8/10,团体通道 10/15。
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 10, "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 20, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
points := map[string]float64{}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
points[row["teamCode"].(string)] = row["totalPoints"].(float64)
|
||||
}
|
||||
// 只算团体通道:A 第 2 名 10 分,B 第 1 名 15 分。
|
||||
if points["A"] != 10 || points["B"] != 15 {
|
||||
t.Fatalf("单位总分 = %v,期望 A:10 / B:15(个人通道不计入)", points)
|
||||
}
|
||||
|
||||
// 个人榜照常保留个人通道的分:B(20)10 分排第一,A(10)8 分排第二。
|
||||
_, env2 = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
|
||||
athletes := decodeData[[]map[string]any](t, env2)
|
||||
if len(athletes) != 2 {
|
||||
t.Fatalf("个人榜行数 = %d,期望 2", len(athletes))
|
||||
}
|
||||
if got := athletes[0]["totalPoints"].(float64); got != 10 {
|
||||
t.Fatalf("个人榜榜首得分 = %v,期望 10", got)
|
||||
}
|
||||
if got := athletes[1]["totalPoints"].(float64); got != 8 {
|
||||
t.Fatalf("个人榜第二名得分 = %v,期望 8(个人通道的分保留)", got)
|
||||
}
|
||||
|
||||
// 单位积分明细与总积分榜同口径:不计入单位总分的流水不出现在明细里。
|
||||
_, env2 = env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
||||
breakdown := decodeData[map[string]any](t, env2)
|
||||
if got := breakdown["totalPoints"].(float64); got != 10 {
|
||||
t.Fatalf("单位 A 明细合计 = %v,期望 10(与总积分榜一致)", got)
|
||||
}
|
||||
items := breakdown["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("单位 A 明细行数 = %d,期望 1(只有团体通道的流水)", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// avg_mode 决定团体平均值的分母:valid 只算有成绩的人,all 把没成绩的人按 0 计入。
|
||||
func TestIndividualTeamAvgModeChangesRanking(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// A 两人(只有一人录成绩 10),B 两人(6 / 7)。
|
||||
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status, avg_mode)
|
||||
VALUES (?, 'E_BOTH', '团体引体向上', 'individual_team', 'auto', 'count',
|
||||
'desc', 1, 1, 'ongoing', 'valid')`, ids.sport)
|
||||
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王五', '男', ?, 1)`, ids.teamA)
|
||||
athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵六', '男', ?, 1)`, ids.teamB)
|
||||
|
||||
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, ids.athleteA)
|
||||
env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, athA2)
|
||||
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, ids.athB)
|
||||
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, athB2)
|
||||
|
||||
var teamProfile int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
|
||||
t.Fatalf("读取团体模板: %v", err)
|
||||
}
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'aggregate', ?, 0)`, eventID, teamProfile)
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": eventID,
|
||||
"results": []map[string]any{
|
||||
{"entryId": entryA1, "resultValue": 10, "status": "ok"},
|
||||
{"entryId": entryB1, "resultValue": 6, "status": "ok"},
|
||||
{"entryId": entryB2, "resultValue": 7, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
|
||||
standings := func(name string) map[string]float64 {
|
||||
t.Helper()
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": eventID,
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("%s发布应 200,得到 %d %+v", name, status, env2.Error)
|
||||
}
|
||||
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
out := map[string]float64{}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
out[row["teamCode"].(string)] = row["totalPoints"].(float64)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// valid:A 平均 10/1 = 10,B 平均 6.5 → A 第一 15 分、B 第二 10 分。
|
||||
if got := standings("valid"); got["A"] != 15 || got["B"] != 10 {
|
||||
t.Fatalf("valid 口径总分 = %v,期望 A:15 / B:10", got)
|
||||
}
|
||||
|
||||
// all:A 平均 (10+0)/2 = 5,B 平均 6.5 → B 第一 15 分、A 第二 10 分。
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
|
||||
"sportId": ids.sport, "code": "E_BOTH", "name": "团体引体向上", "eventType": "individual_team",
|
||||
"resultMode": "auto", "resultType": "count", "compare": "desc", "scored": true,
|
||||
"multiplier": 1, "status": "ongoing", "avgMode": "all",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改 avgMode 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, env2)["avgMode"]; got != "all" {
|
||||
t.Fatalf("改回 avgMode = %v,期望 all", got)
|
||||
}
|
||||
if got := standings("all"); got["A"] != 10 || got["B"] != 15 {
|
||||
t.Fatalf("all 口径总分 = %v,期望 A:10 / B:15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// avgMode 的读写与校验:缺省 valid、非法值 400。
|
||||
func TestEventAvgModeRoundTrip(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 老客户端不传 avgMode:按 valid 存。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
||||
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["avgMode"] != "valid" {
|
||||
t.Fatalf("缺省 avgMode = %v,期望 valid", created["avgMode"])
|
||||
}
|
||||
eventID := int64(created["id"].(float64))
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
|
||||
if got := decodeData[map[string]any](t, env2); got["avgMode"] != "valid" {
|
||||
t.Fatalf("读回 avgMode = %v,期望 valid", got["avgMode"])
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
|
||||
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
|
||||
"avgMode": "bogus",
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ func nullI64(p *int64) any {
|
||||
return *p
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// boolToInt 把布尔写给 SQLite 的 0/1 列。
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullInt(p *int) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// ---------- events ----------
|
||||
|
||||
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark`
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
|
||||
|
||||
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
var (
|
||||
@@ -21,7 +22,7 @@ func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
)
|
||||
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
|
||||
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e)
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode)
|
||||
e.ScheduledAt = strPtr(scheduledAt)
|
||||
e.Venue = strPtr(venue)
|
||||
e.Remark = strPtr(note)
|
||||
@@ -88,6 +89,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||||
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
|
||||
}
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -96,13 +101,13 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark))
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -127,6 +132,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
}
|
||||
|
||||
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -136,13 +145,13 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
|
||||
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
|
||||
WHERE id = ?`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id)
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -211,6 +220,18 @@ func multiplierOrOne(v float64) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
|
||||
func normalizeAvgMode(v string) (string, error) {
|
||||
switch v {
|
||||
case "":
|
||||
return string(domain.AvgModeValid), nil
|
||||
case string(domain.AvgModeValid), string(domain.AvgModeAll):
|
||||
return v, nil
|
||||
default:
|
||||
return "", badRequest("avgMode 只能是 valid / all", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- categories ----------
|
||||
|
||||
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
|
||||
@@ -289,7 +310,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
|
||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -298,15 +319,25 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
|
||||
|
||||
out := make([]channelDTO, 0)
|
||||
for rows.Next() {
|
||||
var c channelDTO
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
|
||||
var (
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
c.CountToTeam = boolPtr(countToTeam != 0)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
|
||||
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
|
||||
func channelCountsToTeam(c channelDTO) bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -330,9 +361,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, badRequest("通道 profileId 必填", i)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil {
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, conflict("同一 scope+basis 只能配置一个通道")
|
||||
}
|
||||
|
||||
@@ -464,7 +464,8 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
}
|
||||
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
|
||||
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
|
||||
// 其中仅记单位的直加,记到个人的乘折算系数。
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
@@ -482,7 +483,7 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.team_id = ? AND l.voided = 0
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY l.id DESC`, teamID)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, internal(err)
|
||||
|
||||
@@ -263,7 +263,7 @@ func joinAnd(parts []string) string {
|
||||
|
||||
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
|
||||
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
|
||||
source_type, source_id, points, voided, created_at, count_to_team FROM score_ledger WHERE voided = 0`)
|
||||
}
|
||||
|
||||
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
|
||||
@@ -282,9 +282,10 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
|
||||
entry sql.NullInt64
|
||||
result sql.NullInt64
|
||||
created sql.NullString
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
|
||||
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
|
||||
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created, &countToTeam); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
l.AthleteID = athlete.Int64
|
||||
@@ -292,6 +293,7 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
|
||||
l.EntryID = entry.Int64
|
||||
l.ResultID = result.Int64
|
||||
l.CreatedAt = created.String
|
||||
l.CountToTeam = countToTeam != 0
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -29,10 +29,10 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
|
||||
)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode
|
||||
FROM events WHERE id = ?`, eventID).
|
||||
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark)
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Event{}, notFound("小项不存在")
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
|
||||
|
||||
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -89,10 +89,17 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
|
||||
|
||||
out := make([]domain.ScoreChannel, 0)
|
||||
for rows.Next() {
|
||||
var c domain.ScoreChannel
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
|
||||
var (
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
|
||||
if countToTeam == 0 {
|
||||
c.CountToTeam = boolPtr(false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -379,10 +386,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
}
|
||||
for _, l := range ledger {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
|
||||
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
|
||||
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
|
||||
return domain.EventScore{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status,
|
||||
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '')
|
||||
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, ''), avg_mode
|
||||
FROM events
|
||||
ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
@@ -198,7 +198,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare,
|
||||
&recordEligible, &scored, &e.Multiplier, &status,
|
||||
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark,
|
||||
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark, &e.AvgMode,
|
||||
); err != nil {
|
||||
return fmt.Errorf("解析小项: %w", err)
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func splitTieBreak(raw string) []string {
|
||||
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
|
||||
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points
|
||||
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0 ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取积分流水: %w", err)
|
||||
@@ -271,11 +271,13 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
var (
|
||||
e domain.LedgerEntry
|
||||
sourceType string
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points, &countToTeam); err != nil {
|
||||
return nil, fmt.Errorf("解析积分流水: %w", err)
|
||||
}
|
||||
e.SourceType = domain.SourceType(sourceType)
|
||||
e.CountToTeam = countToTeam != 0
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -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 {
|
||||
@@ -258,19 +258,25 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
|
||||
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"`
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 「个人+团体」(individual_team)双计需要的三处结构:
|
||||
-- 1) score_ledger 加 count_to_team:个人通道的流水只记个人,不进单位总分;
|
||||
-- 2) event_score_channels 加同名字段:按通道决定它产出的流水算不算单位分;
|
||||
-- 3) events 加 avg_mode:团体成绩按成员成绩取平均时的分母口径。
|
||||
--
|
||||
-- score_ledger 没有子表,重建最省事(写法与 0012 一致,既有索引一并保留);
|
||||
-- score_ledger 自身引用 teams/athletes/events/entries/results,删除旧表时
|
||||
-- 需要 defer_foreign_keys 把校验推迟到事务提交,避免重建期间误报。
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE score_ledger_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id INTEGER NOT NULL REFERENCES teams(id),
|
||||
athlete_id INTEGER REFERENCES athletes(id),
|
||||
event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
|
||||
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
|
||||
result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')),
|
||||
source_id INTEGER NOT NULL,
|
||||
points REAL NOT NULL,
|
||||
count_to_team INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team IN (0, 1)),
|
||||
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
|
||||
voided_by INTEGER,
|
||||
voided_at TEXT,
|
||||
void_reason TEXT,
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 历史流水一律计入单位总分,保持升级前后榜单一分不差。
|
||||
INSERT INTO score_ledger_new (id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, count_to_team, voided, voided_by,
|
||||
voided_at, void_reason, created_by, created_at)
|
||||
SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, 1, voided, voided_by,
|
||||
voided_at, void_reason, created_by, created_at
|
||||
FROM score_ledger;
|
||||
|
||||
DROP TABLE score_ledger;
|
||||
ALTER TABLE score_ledger_new RENAME TO score_ledger;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided);
|
||||
CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided);
|
||||
CREATE INDEX IF NOT EXISTS idx_score_ledger_entry_voided ON score_ledger(entry_id, voided);
|
||||
|
||||
-- 通道开关与平均值口径都是加列,不涉及既有数据,ADD COLUMN 即可
|
||||
-- (SQLite 允许列级 CHECK,且不回查历史行,旧行取默认值)。
|
||||
ALTER TABLE event_score_channels ADD COLUMN count_to_team INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (count_to_team IN (0, 1));
|
||||
|
||||
ALTER TABLE events ADD COLUMN avg_mode TEXT NOT NULL DEFAULT 'valid'
|
||||
CHECK (avg_mode IN ('valid', 'all'));
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
|
||||
)
|
||||
|
||||
// 0016 迁移:events.event_type 增加 individual_team。events 被多张子表以级联/置空引用,
|
||||
// 迁移用 writable_schema 就地改写建表语句,本文件的用例守住「数据不丢 + 新 CHECK 生效」。
|
||||
// 迁移用例:0016 给 events.event_type 增加 individual_team(writable_schema 就地改写建表语句),
|
||||
// 0017 给 score_ledger / event_score_channels 加 count_to_team、给 events 加 avg_mode。
|
||||
// 两个用例都守住同一条底线:老库升级后数据一行不少、新定义与默认值生效。
|
||||
|
||||
func openEventDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
@@ -35,7 +36,7 @@ func openEventDB(t *testing.T) *sql.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// applyBefore 执行 0016 之前的迁移并登记到 schema_migrations,
|
||||
// applyBefore 执行给定迁移之前的全部迁移并登记到 schema_migrations,
|
||||
// 模拟老库在升级前的状态(store.MigrateEvent 只能补跑未登记的迁移)。
|
||||
func applyBefore(t *testing.T, db *sql.DB, name string) {
|
||||
t.Helper()
|
||||
@@ -141,12 +142,12 @@ func TestMigration0016PreservesExistingRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016。
|
||||
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016(及其后的迁移)。
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("升级迁移: %v", err)
|
||||
}
|
||||
if got := countRows(t, db, "schema_migrations"); got != 16 {
|
||||
t.Errorf("迁移登记数 = %d,期望 16", got)
|
||||
if got := countRows(t, db, "schema_migrations"); got != 17 {
|
||||
t.Errorf("迁移登记数 = %d,期望 17", got)
|
||||
}
|
||||
|
||||
for table, want := range map[string]int{
|
||||
@@ -237,3 +238,210 @@ func TestMigration0016PreservesExistingRows(t *testing.T) {
|
||||
t.Error("迁移后存在外键悬挂")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 0017 迁移:score_ledger 加 count_to_team(重建表)、event_score_channels 加同名字段、
|
||||
// events 加 avg_mode。本文件的用例守住「老数据不丢 + 默认值正确 + 新 CHECK 生效」。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 全量迁移(run 两次验幂等)后,三张表都应按新定义读写。
|
||||
func TestMigrate0017AppliesCountToTeamAndAvgMode(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E1', '团体引体向上', 'individual_team')`,
|
||||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 未指定时取默认值:通道计入单位总分、平均值口径只算有效成绩。
|
||||
var (
|
||||
countToTeam int
|
||||
avgMode string
|
||||
)
|
||||
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
|
||||
t.Fatalf("读通道: %v", err)
|
||||
}
|
||||
if countToTeam != 1 {
|
||||
t.Errorf("通道 count_to_team 默认值 = %d,期望 1", countToTeam)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
|
||||
t.Fatalf("读小项: %v", err)
|
||||
}
|
||||
if avgMode != "valid" {
|
||||
t.Errorf("avg_mode 默认值 = %q,期望 valid", avgMode)
|
||||
}
|
||||
|
||||
// 显式写:个人通道不计入单位总分。
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("改通道开关: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode)
|
||||
VALUES (1, 'E2', '团体引体向上', 'individual_team', 'all')`); err != nil {
|
||||
t.Fatalf("写入 avg_mode=all: %v", err)
|
||||
}
|
||||
// 非法取值被 CHECK 挡住。
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 2 WHERE event_id = 1`); err == nil {
|
||||
t.Error("count_to_team=2 应被拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
|
||||
t.Error("avg_mode='bogus' 应被拒绝")
|
||||
}
|
||||
|
||||
// 重建 score_ledger 后:默认计入、单条可置 0、CHECK 生效。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 'result', 1, 10)`); err != nil {
|
||||
t.Fatalf("写入流水: %v", err)
|
||||
}
|
||||
var ledgerCount int
|
||||
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE id = 1`).Scan(&ledgerCount); err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
if ledgerCount != 1 {
|
||||
t.Errorf("流水 count_to_team 默认值 = %d,期望 1", ledgerCount)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE score_ledger SET count_to_team = 2 WHERE id = 1`); err == nil {
|
||||
t.Error("流水 count_to_team=2 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 老库升级:0016 之前的赛事库补跑 0016 + 0017,行数与索引一个不少,新列取默认值。
|
||||
func TestMigration0017PreservesExistingRows(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
applyBefore(t, db, "0017")
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||||
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
|
||||
`INSERT INTO score_ledger(team_id, source_type, source_id, points, void_reason)
|
||||
VALUES (1, 'adjustment', 1, -3, '人工扣分')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 走真实升级路径:只补跑未登记的 0016、0017。
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("升级迁移: %v", err)
|
||||
}
|
||||
if got := countRows(t, db, "schema_migrations"); got != 17 {
|
||||
t.Errorf("迁移登记数 = %d,期望 17", got)
|
||||
}
|
||||
for table, want := range map[string]int{
|
||||
"events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1,
|
||||
"results": 1, "score_ledger": 2,
|
||||
} {
|
||||
if got := countRows(t, db, table); got != want {
|
||||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史流水原样保留(含分值、作废原因),新列一律为「计入单位总分」。
|
||||
rows, err := db.Query(`SELECT id, points, count_to_team, COALESCE(void_reason, '') FROM score_ledger ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
wantPoints := []float64{10, -3}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
points float64
|
||||
countToTeam int
|
||||
reason string
|
||||
)
|
||||
if err := rows.Scan(&id, &points, &countToTeam, &reason); err != nil {
|
||||
t.Fatalf("扫描流水: %v", err)
|
||||
}
|
||||
if i >= len(wantPoints) || points != wantPoints[i] {
|
||||
t.Fatalf("第 %d 条流水分值 = %v,期望 %v", i, points, wantPoints[i])
|
||||
}
|
||||
if countToTeam != 1 {
|
||||
t.Errorf("第 %d 条流水 count_to_team = %d,期望 1", i, countToTeam)
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i != len(wantPoints) {
|
||||
t.Fatalf("流水条数 = %d,期望 %d", i, len(wantPoints))
|
||||
}
|
||||
|
||||
// 自增序列延续,避免重建后新流水撞号。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
|
||||
VALUES (1, 'adjustment', 9, 2)`); err != nil {
|
||||
t.Fatalf("重建后写入流水: %v", err)
|
||||
}
|
||||
var newLedgerID int64
|
||||
if err := db.QueryRow(`SELECT max(id) FROM score_ledger`).Scan(&newLedgerID); err != nil {
|
||||
t.Fatalf("读新流水 id: %v", err)
|
||||
}
|
||||
if newLedgerID <= int64(len(wantPoints)) {
|
||||
t.Errorf("score_ledger 自增序列未延续,新 id = %d", newLedgerID)
|
||||
}
|
||||
|
||||
// 通道与旧小项取默认值。
|
||||
var countToTeam int
|
||||
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
|
||||
t.Fatalf("读通道: %v", err)
|
||||
}
|
||||
if countToTeam != 1 {
|
||||
t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam)
|
||||
}
|
||||
var avgMode string
|
||||
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
|
||||
t.Fatalf("读小项: %v", err)
|
||||
}
|
||||
if avgMode != "valid" {
|
||||
t.Errorf("老小项 avg_mode = %q,期望 valid", avgMode)
|
||||
}
|
||||
|
||||
// 重建表后索引一个不少(0012 补的 (entry_id, voided) 也在内)。
|
||||
var indexCount int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
|
||||
('idx_score_ledger_team_voided', 'idx_score_ledger_source',
|
||||
'idx_score_ledger_athlete', 'idx_score_ledger_entry_voided')`).Scan(&indexCount); err != nil {
|
||||
t.Fatalf("查索引: %v", err)
|
||||
}
|
||||
if indexCount != 4 {
|
||||
t.Errorf("score_ledger 索引缺失,命中 %d 个", indexCount)
|
||||
}
|
||||
|
||||
// 外键仍然生效,且没有悬挂。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
|
||||
VALUES (999, 'adjustment', 1, 1)`); err == nil {
|
||||
t.Error("重建后 score_ledger.team_id 外键应仍然生效")
|
||||
}
|
||||
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
|
||||
if err != nil {
|
||||
t.Fatalf("外键检查: %v", err)
|
||||
}
|
||||
defer func() { _ = fkRows.Close() }()
|
||||
if fkRows.Next() {
|
||||
t.Error("迁移后存在外键悬挂")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { normalizeAvgMode } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
AvgMode,
|
||||
CurrentUser,
|
||||
DraftResultInput,
|
||||
Entry,
|
||||
@@ -109,6 +111,8 @@ interface EventSeed {
|
||||
compare: Event['compare']
|
||||
scored: boolean
|
||||
categoryName: string
|
||||
/** 「个人+团体」的团体成绩平均口径;缺省按 valid。 */
|
||||
avgMode?: AvgMode
|
||||
/** 已发布的成绩:单位序号(从 1 开始)+ 成绩文本 + 手动名次(团体项目用)。 */
|
||||
published?: Array<[number, string] | [number, string, number]>
|
||||
}
|
||||
@@ -314,6 +318,7 @@ class MockDb {
|
||||
venue: seed.sportId === 1 ? '田径场' : '体育馆',
|
||||
sortOrder: index + 1,
|
||||
remark: null,
|
||||
avgMode: normalizeAvgMode(seed.avgMode),
|
||||
}))
|
||||
|
||||
this.categories = EVENT_SEEDS.map((seed) => ({
|
||||
@@ -327,12 +332,12 @@ class MockDb {
|
||||
sortOrder: 1,
|
||||
}))
|
||||
|
||||
// 「个人+团体」同时配个人通道与团体通道,其余类型单通道。
|
||||
// 「个人+团体」按规则包默认模板配两条通道:个人通道只记个人分,团体通道取队内平均。
|
||||
this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => {
|
||||
if (seed.eventType === 'individual_team') {
|
||||
return [
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1 },
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 2 },
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false },
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true },
|
||||
]
|
||||
}
|
||||
return [{
|
||||
@@ -342,6 +347,7 @@ class MockDb {
|
||||
basis: seed.resultMode === 'manual' ? 'manual' : 'result',
|
||||
profileId: seed.eventType === 'individual' ? 1 : 2,
|
||||
sortOrder: 1,
|
||||
countToTeam: true,
|
||||
}]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
ChannelBasis,
|
||||
ChannelScope,
|
||||
CompareMode,
|
||||
@@ -15,7 +17,7 @@ import type {
|
||||
*
|
||||
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
|
||||
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
|
||||
* 个人通道分再按 standings.individual_to_team_weight 折算进单位总分。
|
||||
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
|
||||
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
|
||||
*/
|
||||
|
||||
@@ -50,6 +52,8 @@ interface Candidate {
|
||||
rankIn: number
|
||||
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
|
||||
subject: number
|
||||
/** 该候选计入单位总分时的归属单位。 */
|
||||
teamId: number
|
||||
}
|
||||
|
||||
interface RankedRow {
|
||||
@@ -124,11 +128,11 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
|
||||
if (row.status !== 'ok') continue
|
||||
if (basis === 'manual') {
|
||||
if (row.rank === null || row.rank <= 0) continue
|
||||
out.push({ row, value: 0, rankIn: row.rank, subject: 0 })
|
||||
out.push({ row, value: 0, rankIn: row.rank, subject: 0, teamId: row.teamId })
|
||||
continue
|
||||
}
|
||||
if (row.resultValue === null) continue
|
||||
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0 })
|
||||
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0, teamId: row.teamId })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -178,26 +182,51 @@ function sortManual(cands: Candidate[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道:先把同一主体的成绩求和,再按值排名(scoring.go scoreByAggregate)。
|
||||
* 运动员维度缺 athleteId 的行按服务端口径直接跳过(subject == 0)。
|
||||
* 聚合通道:先把同一主体的成绩取平均,再按平均值排名(scoring.go scoreByAggregate)。
|
||||
*
|
||||
* 分母由 avgMode 决定:valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,
|
||||
* 没成绩的按 0 计入。scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
|
||||
*/
|
||||
function aggregateCandidates(rows: readonly ScorableRow[], channel: ScoreChannel): Candidate[] {
|
||||
function aggregateCandidates(
|
||||
rows: readonly ScorableRow[],
|
||||
channel: ScoreChannel,
|
||||
avgMode: AvgMode,
|
||||
): Candidate[] {
|
||||
const order: number[] = []
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number }>()
|
||||
for (const candidate of collectCandidates(rows, 'result')) {
|
||||
const subject = channel.scope === 'team' ? candidate.row.teamId : (candidate.row.athleteId ?? 0)
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
|
||||
for (const row of rows) {
|
||||
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
|
||||
if (subject === 0) continue
|
||||
const bucket = buckets.get(subject)
|
||||
const value = row.status === 'ok' ? row.resultValue : null
|
||||
let bucket = buckets.get(subject)
|
||||
if (bucket === undefined) {
|
||||
buckets.set(subject, { row: candidate.row, total: candidate.value })
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名。
|
||||
if (value === null && avgMode !== 'all') continue
|
||||
bucket = { row, total: 0, valid: 0, registered: 0 }
|
||||
buckets.set(subject, bucket)
|
||||
order.push(subject)
|
||||
} else {
|
||||
bucket.total += candidate.value
|
||||
}
|
||||
bucket.registered += 1
|
||||
if (value !== null) {
|
||||
bucket.total += value
|
||||
bucket.valid += 1
|
||||
}
|
||||
}
|
||||
return order.map((subject) => {
|
||||
const bucket = buckets.get(subject) as { row: ScorableRow; total: number }
|
||||
return { row: bucket.row, value: bucket.total, rankIn: 0, subject }
|
||||
const bucket = buckets.get(subject) as {
|
||||
row: ScorableRow
|
||||
total: number
|
||||
valid: number
|
||||
registered: number
|
||||
}
|
||||
const count = avgMode === 'all' ? bucket.registered : bucket.valid
|
||||
return {
|
||||
row: bucket.row,
|
||||
value: count > 0 ? bucket.total / count : 0,
|
||||
rankIn: 0,
|
||||
subject,
|
||||
teamId: bucket.row.teamId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +245,7 @@ function scoreChannel(
|
||||
|
||||
let groups: TieGroup[]
|
||||
if (basis === 'aggregate') {
|
||||
const aggregated = aggregateCandidates(rows, channel)
|
||||
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
|
||||
sortByValue(aggregated, event.compare)
|
||||
groups = groupByValue(aggregated)
|
||||
} else if (basis === 'manual') {
|
||||
@@ -242,7 +271,7 @@ function scoreChannel(
|
||||
for (const member of group.members) {
|
||||
out.push({
|
||||
entryId: basis === 'aggregate' ? 0 : member.row.entryId,
|
||||
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.row.teamId,
|
||||
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.teamId,
|
||||
scope: channel.scope,
|
||||
rank: group.startRank,
|
||||
points,
|
||||
@@ -262,6 +291,7 @@ function rankOnlyChannel(event: Event): ScoreChannel {
|
||||
basis: event.resultMode === 'manual' ? 'manual' : 'result',
|
||||
profileId: 0,
|
||||
sortOrder: 0,
|
||||
countToTeam: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,11 +338,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
|
||||
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
|
||||
const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null)
|
||||
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
|
||||
const countToTeam = normalizeCountToTeam(channel.countToTeam)
|
||||
for (const row of ranked) {
|
||||
if (row.entryId > 0) {
|
||||
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
|
||||
if (countToTeam) {
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
|
||||
}
|
||||
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
|
||||
const current = rankScope.get(row.entryId)
|
||||
if (current === undefined || (current === 'team' && row.scope === 'athlete')) {
|
||||
@@ -320,6 +354,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
rankScope.set(row.entryId, row.scope)
|
||||
}
|
||||
}
|
||||
if (!countToTeam) continue
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeCountToTeam } from '@/shared'
|
||||
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
@@ -19,7 +20,7 @@ const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
|
||||
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
|
||||
{ value: 'result', label: '按成绩自动排名' },
|
||||
{ value: 'manual', label: '手动录名次' },
|
||||
{ value: 'aggregate', label: '按团体汇总(预留)' },
|
||||
{ value: 'aggregate', label: '按队内平均' },
|
||||
]
|
||||
|
||||
interface ChannelRow {
|
||||
@@ -28,6 +29,7 @@ interface ChannelRow {
|
||||
basis: ChannelBasis
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
countToTeam: boolean
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
@@ -78,6 +80,7 @@ function toRow(channel: ScoreChannel): ChannelRow {
|
||||
basis: channel.basis,
|
||||
profileId: String(channel.profileId),
|
||||
sortOrder: String(channel.sortOrder),
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +93,7 @@ function addRow(): void {
|
||||
basis: 'result',
|
||||
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
countToTeam: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -131,6 +135,7 @@ function buildInputs(): ScoreChannelInput[] {
|
||||
basis: row.basis,
|
||||
profileId: Number(row.profileId),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
countToTeam: row.countToTeam,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -205,6 +210,10 @@ async function save(): Promise<void> {
|
||||
该小项标记为「不计分」,通道配置会保存但不参与积分计算。
|
||||
</div>
|
||||
|
||||
<div class="bd-note count-to-team-note">
|
||||
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
@@ -212,6 +221,7 @@ async function save(): Promise<void> {
|
||||
<th class="bd-select">名次范围</th>
|
||||
<th class="bd-select">名次来源</th>
|
||||
<th>积分模板</th>
|
||||
<th class="bd-select">计入单位总分</th>
|
||||
<th class="bd-cell-mini">排序</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
@@ -227,6 +237,9 @@ async function save(): Promise<void> {
|
||||
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
|
||||
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
|
||||
团体成绩 = 该单位成员成绩的平均值
|
||||
</span>
|
||||
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -237,6 +250,12 @@ async function save(): Promise<void> {
|
||||
<span class="profile-summary bd-note">{{ profileSummary(row.profileId) }}</span>
|
||||
<span v-if="errors[row.key]?.profile" class="row-error">{{ errors[row.key]?.profile }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<label class="count-to-team" :title="row.countToTeam ? '计入单位总分' : '不计入单位总分'">
|
||||
<input v-model="row.countToTeam" type="checkbox" :disabled="!data.canWrite.value" />
|
||||
<span>{{ row.countToTeam ? '计入' : '不计入' }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
|
||||
</td>
|
||||
@@ -249,7 +268,7 @@ async function save(): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项没有计分通道</div>
|
||||
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
|
||||
@@ -297,4 +316,15 @@ async function save(): Promise<void> {
|
||||
color: var(--mx-amber);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.count-to-team-note {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.count-to-team {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
CompareMode,
|
||||
Event,
|
||||
EventInput,
|
||||
@@ -101,9 +103,10 @@ const channelSummary = computed(() =>
|
||||
return {
|
||||
id: channel.id,
|
||||
scope: channel.scope === 'athlete' ? '个人名次' : '团体名次',
|
||||
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '团体汇总',
|
||||
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '按队内平均',
|
||||
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||
award: profile?.awardRanks ?? null,
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -246,6 +249,8 @@ interface EventForm {
|
||||
venue: string
|
||||
sortOrder: string
|
||||
remark: string
|
||||
/** 平均值口径只在录入页改;这里保留原值,避免编辑小项时被重置成默认。 */
|
||||
avgMode: AvgMode
|
||||
}
|
||||
|
||||
type EventField = keyof EventForm
|
||||
@@ -284,6 +289,7 @@ function emptyEventForm(): EventForm {
|
||||
venue: '',
|
||||
sortOrder: '0',
|
||||
remark: '',
|
||||
avgMode: 'valid',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +336,7 @@ function openEditEvent(event: Event): void {
|
||||
venue: event.venue ?? '',
|
||||
sortOrder: String(event.sortOrder),
|
||||
remark: event.remark ?? '',
|
||||
avgMode: normalizeAvgMode(event.avgMode),
|
||||
}
|
||||
eventErrors.value = {}
|
||||
eventFormError.value = null
|
||||
@@ -374,6 +381,7 @@ async function submitEvent(): Promise<void> {
|
||||
venue: eventForm.value.venue.trim() === '' ? null : eventForm.value.venue.trim(),
|
||||
sortOrder: Number(eventForm.value.sortOrder),
|
||||
remark: eventForm.value.remark.trim() === '' ? null : eventForm.value.remark.trim(),
|
||||
avgMode: eventForm.value.avgMode,
|
||||
}
|
||||
try {
|
||||
const saved = await data.saveEvent(input, eventEditingId.value)
|
||||
@@ -568,6 +576,7 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<span>{{ channel.basis }}</span>
|
||||
<span class="bd-mono">{{ channel.profile }}</span>
|
||||
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
|
||||
<span v-if="!channel.countToTeam" class="mx-badge mx-badge-warning">不计入单位总分</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
Athlete,
|
||||
AthleteInput,
|
||||
@@ -94,11 +95,16 @@ const SEED_PROFILES: ScoringProfile[] = [
|
||||
]
|
||||
|
||||
interface EventSeed {
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels'>
|
||||
/** avgMode 统一由构造函数补默认值,示例数据里不必逐个写。 */
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode'>
|
||||
category: string
|
||||
scope: ScoreChannel['scope']
|
||||
basis: ScoreChannel['basis']
|
||||
profileId: number
|
||||
/** 通道是否计入单位总分;缺省 true。 */
|
||||
countToTeam?: boolean
|
||||
/** 团体成绩的平均口径;缺省 valid。 */
|
||||
avgMode?: Event['avgMode']
|
||||
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
|
||||
entryCount: number
|
||||
}
|
||||
@@ -206,7 +212,7 @@ export class MockBaseData {
|
||||
constructor() {
|
||||
SEED_EVENTS.forEach((seed, index) => {
|
||||
const id = 101 + index
|
||||
this.events.push({ ...seed.event, id })
|
||||
this.events.push({ ...seed.event, id, avgMode: normalizeAvgMode(seed.avgMode) })
|
||||
this.entryCounts.set(id, seed.entryCount)
|
||||
this.categories.push({
|
||||
id: (this.nextCategoryId += 1),
|
||||
@@ -225,6 +231,7 @@ export class MockBaseData {
|
||||
basis: seed.basis,
|
||||
profileId: seed.profileId,
|
||||
sortOrder: 1,
|
||||
countToTeam: seed.countToTeam ?? true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -316,7 +323,7 @@ export class MockBaseData {
|
||||
createEvent(input: EventInput): Event {
|
||||
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
const id = (this.nextEventId += 1)
|
||||
const event: Event = { ...input, id }
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
this.events = [...this.events, event]
|
||||
this.entryCounts.set(id, 0)
|
||||
return { ...event }
|
||||
@@ -328,7 +335,7 @@ export class MockBaseData {
|
||||
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
|
||||
throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
}
|
||||
const event: Event = { ...input, id }
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
this.events[index] = event
|
||||
return { ...event }
|
||||
}
|
||||
@@ -370,7 +377,12 @@ export class MockBaseData {
|
||||
}
|
||||
this.channels = this.channels.filter((item) => item.eventId !== eventId)
|
||||
for (const input of inputs) {
|
||||
this.channels.push({ ...input, id: (this.nextChannelId += 1), eventId })
|
||||
this.channels.push({
|
||||
...input,
|
||||
id: (this.nextChannelId += 1),
|
||||
eventId,
|
||||
countToTeam: normalizeCountToTeam(input.countToTeam),
|
||||
})
|
||||
}
|
||||
return this.getEvent(eventId).channels ?? []
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
eventInputOf,
|
||||
formatPoints,
|
||||
hasAggregateChannel,
|
||||
parseResultValue,
|
||||
type AvgMode,
|
||||
type DraftResultInput,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
@@ -98,6 +102,9 @@ const categories = computed(() => detail.value?.categories ?? [])
|
||||
const channels = computed(() => detail.value?.channels ?? [])
|
||||
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||||
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
|
||||
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||||
|
||||
function unitOf(type: Event['resultType']): string {
|
||||
if (type === 'time') return 's'
|
||||
@@ -320,6 +327,7 @@ async function selectEvent(id: number): Promise<void> {
|
||||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||||
if (seq !== requestSeq) return
|
||||
detail.value = event
|
||||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||||
allRows.value = entries.map(toRow)
|
||||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||||
@@ -332,6 +340,43 @@ async function selectEvent(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
|
||||
*
|
||||
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
|
||||
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
|
||||
*/
|
||||
async function pickAvgMode(mode: AvgMode): Promise<void> {
|
||||
const event = detail.value
|
||||
if (event === null || !canWrite.value) return
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
|
||||
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
const next = selectAvgMode(avgMode.value, mode)
|
||||
avgMode.value = next.state
|
||||
if (!next.shouldSave) return
|
||||
try {
|
||||
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
|
||||
avgMode.value = settleAvgMode(avgMode.value, true)
|
||||
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
|
||||
detail.value = {
|
||||
...saved,
|
||||
categories: saved.categories ?? event.categories,
|
||||
channels: saved.channels ?? event.channels,
|
||||
}
|
||||
await data.refreshEvents()
|
||||
toast.success(
|
||||
'平均值口径已保存',
|
||||
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
|
||||
)
|
||||
} catch (caught) {
|
||||
avgMode.value = settleAvgMode(avgMode.value, false)
|
||||
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (selectedEventId.value === null) return
|
||||
const id = selectedEventId.value
|
||||
@@ -676,6 +721,29 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAvgMode" class="avg-mode-bar">
|
||||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||||
<label
|
||||
v-for="option in AVG_MODE_OPTIONS"
|
||||
:key="option.value"
|
||||
class="avg-mode-option"
|
||||
:class="{ active: avgMode.mode === option.value }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="avg-mode"
|
||||
:value="option.value"
|
||||
:checked="avgMode.mode === option.value"
|
||||
:disabled="!canWrite || avgMode.saving"
|
||||
@change="pickAvgMode(option.value)"
|
||||
/>
|
||||
<span class="avg-mode-text">{{ option.label }}</span>
|
||||
<span class="avg-mode-hint">{{ option.hint }}</span>
|
||||
</label>
|
||||
<span v-if="avgMode.saving" class="avg-mode-hint">保存中…</span>
|
||||
<span class="avg-mode-note">团体成绩 = 该单位成员成绩的平均值</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isPublished" class="published-note">
|
||||
<MxIcon name="lock" :size="15" />
|
||||
该组已发布,成绩与名次已锁定上屏:如需修改,请先「撤销发布」。草稿态不会上屏。
|
||||
@@ -945,6 +1013,45 @@ watch(
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.avg-mode-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
border: 1px solid var(--mx-primary);
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
.avg-mode-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.avg-mode-option {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.avg-mode-option.active .avg-mode-text {
|
||||
color: var(--mx-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.avg-mode-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.avg-mode-note {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.published-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { normalizeAvgMode } from '@/shared'
|
||||
import type { AvgMode } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩录入页「平均值口径」选择的状态机。
|
||||
*
|
||||
* 口径是随项目存的(PUT /api/events/{id}),点一下就立即保存,不参与成绩草稿的保存流程,
|
||||
* 所以状态单独放在这里,页面只负责发请求与提示。
|
||||
*/
|
||||
|
||||
export interface AvgModeOption {
|
||||
value: AvgMode
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const AVG_MODE_OPTIONS: readonly AvgModeOption[] = [
|
||||
{ value: 'valid', label: '只算有效成绩', hint: '没有有效成绩的人不进分母' },
|
||||
{ value: 'all', label: '全部报名人员都计入', hint: '没成绩的按 0 计入平均' },
|
||||
]
|
||||
|
||||
export interface AvgModeState {
|
||||
eventId: number | null
|
||||
/** 已保存到服务端的口径。 */
|
||||
saved: AvgMode
|
||||
/** 当前选择;保存中先按新值显示。 */
|
||||
mode: AvgMode
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
/** 切换 / 首次载入项目时重置,避免把上一个小项的口径带过来。 */
|
||||
export function loadAvgMode(eventId: number | null, mode: unknown): AvgModeState {
|
||||
const value = normalizeAvgMode(mode)
|
||||
return { eventId, saved: value, mode: value, saving: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点选口径:与当前值相同、或上一次保存还没回来时不发请求
|
||||
* (同一时间只允许一个在途 PUT,避免两次响应互相覆盖)。
|
||||
*/
|
||||
export function selectAvgMode(
|
||||
state: AvgModeState,
|
||||
mode: AvgMode,
|
||||
): { state: AvgModeState; shouldSave: boolean } {
|
||||
if (mode === state.mode || state.saving) return { state, shouldSave: false }
|
||||
return { state: { ...state, mode, saving: true }, shouldSave: true }
|
||||
}
|
||||
|
||||
/** 保存结果回落:成功记住新值,失败退回上一次已保存的值。 */
|
||||
export function settleAvgMode(state: AvgModeState, ok: boolean): AvgModeState {
|
||||
if (!state.saving) return state
|
||||
const value = ok ? state.mode : state.saved
|
||||
return { ...state, saving: false, saved: value, mode: value }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
|
||||
|
||||
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('载入口径时归一化并清掉上一次的保存状态', () => {
|
||||
const state = loadAvgMode(108, 'all')
|
||||
assert.deepEqual(state, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
// 旧数据 / 非法值一律按 valid。
|
||||
assert.equal(loadAvgMode(108, undefined).mode, 'valid')
|
||||
assert.equal(loadAvgMode(null, 'avg').mode, 'valid')
|
||||
})
|
||||
|
||||
test('选同一个口径或上一次保存未返回时不发请求', () => {
|
||||
const state = loadAvgMode(108, 'valid')
|
||||
|
||||
const same = selectAvgMode(state, 'valid')
|
||||
assert.equal(same.shouldSave, false)
|
||||
assert.equal(same.state, state)
|
||||
|
||||
const changed = selectAvgMode(state, 'all')
|
||||
assert.equal(changed.shouldSave, true)
|
||||
assert.equal(changed.state.mode, 'all')
|
||||
assert.equal(changed.state.saving, true)
|
||||
assert.equal(changed.state.saved, 'valid')
|
||||
|
||||
// 在途请求只允许一个:保存中再点另一个口径被忽略。
|
||||
const again = selectAvgMode(changed.state, 'valid')
|
||||
assert.equal(again.shouldSave, false)
|
||||
assert.equal(again.state.mode, 'all')
|
||||
})
|
||||
|
||||
test('保存成功记住新口径,失败退回原口径', () => {
|
||||
const pending = selectAvgMode(loadAvgMode(108, 'valid'), 'all').state
|
||||
|
||||
const ok = settleAvgMode(pending, true)
|
||||
assert.deepEqual(ok, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
|
||||
const failed = settleAvgMode(pending, false)
|
||||
assert.deepEqual(failed, { eventId: 108, saved: 'valid', mode: 'valid', saving: false })
|
||||
|
||||
// 没有在途请求时回落是空操作。
|
||||
const idle = loadAvgMode(108, 'valid')
|
||||
assert.equal(settleAvgMode(idle, true), idle)
|
||||
})
|
||||
|
||||
test('口径选项覆盖两种分母口径与「按 0 计入」提示', () => {
|
||||
assert.deepEqual(
|
||||
AVG_MODE_OPTIONS.map((option) => option.value),
|
||||
['valid', 'all'],
|
||||
)
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '只算有效成绩'))
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '全部报名人员都计入' && option.hint === '没成绩的按 0 计入平均'))
|
||||
})
|
||||
|
||||
test('录入页只在存在 aggregate 通道时显示口径选择,并整体回写小项', () => {
|
||||
assert.match(ENTRY_SOURCE, /hasAggregateChannel\(channels\.value\)/)
|
||||
assert.match(ENTRY_SOURCE, /平均值口径/)
|
||||
assert.match(ENTRY_SOURCE, /v-if="showAvgMode"/)
|
||||
// 口径改动走 PUT /api/events/{id},不能混进成绩草稿的保存流程。
|
||||
assert.match(ENTRY_SOURCE, /client\.events\.update\(event\.id, eventInputOf\(event, \{ avgMode: mode \}\)\)/)
|
||||
})
|
||||
|
||||
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/)
|
||||
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
|
||||
assert.match(CHANNELS_SOURCE, /计入单位总分/)
|
||||
assert.match(CHANNELS_SOURCE, /只记在个人头上/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeam: row\.countToTeam/)
|
||||
})
|
||||
@@ -24,6 +24,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -33,7 +34,7 @@ function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, ...overrides }
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
|
||||
@@ -258,6 +259,83 @@ test('团体行(athleteId 为 null)按单位通道正常拿名次与积分',
|
||||
close(result.teamPoints.get(101) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('countToTeam=false 的通道只算个人分,不进单位总分', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100)],
|
||||
makeContext({
|
||||
channels: [makeChannel({ basis: 'result', countToTeam: false })],
|
||||
profiles: [profile],
|
||||
weight: 2,
|
||||
}),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 10)
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 0)
|
||||
close(result.teamPoints.get(100) ?? 0, 0)
|
||||
// 名次照算,只是分不落到单位头上。
|
||||
assert.equal(result.ranks.get(1), 1)
|
||||
})
|
||||
|
||||
test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => {
|
||||
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] })
|
||||
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] })
|
||||
const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [
|
||||
makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }),
|
||||
makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }),
|
||||
],
|
||||
profiles: [athleteProfile, teamProfile],
|
||||
}),
|
||||
)
|
||||
// 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。
|
||||
close(result.rowPoints.get(2) ?? 0, 10)
|
||||
close(result.rowPoints.get(1) ?? 0, 8)
|
||||
// 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。
|
||||
// 个人通道若也计入,100 队会多出个人第 1 名的 10 分。
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
close(result.teamPoints.get(101) ?? 0, 8)
|
||||
})
|
||||
|
||||
test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
// 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。
|
||||
close(result.teamPoints.get(101) ?? 0, 10)
|
||||
close(result.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)]
|
||||
const context = (avgMode: Event['avgMode']): ScoreContext =>
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
})
|
||||
|
||||
// valid:100 队只按两条有效成绩平均(20)→ 第一。
|
||||
const valid = computeEventScore(makeRows(), context('valid'))
|
||||
close(valid.teamPoints.get(100) ?? 0, 10)
|
||||
close(valid.teamPoints.get(101) ?? 0, 5)
|
||||
|
||||
// all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。
|
||||
const all = computeEventScore(makeRows(), context('all'))
|
||||
close(all.teamPoints.get(101) ?? 0, 10)
|
||||
close(all.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
BreakdownItem,
|
||||
ChannelBasis,
|
||||
ChannelScope,
|
||||
CompareMode,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
@@ -16,6 +19,7 @@ import type {
|
||||
ResultMode,
|
||||
ResultStatus,
|
||||
ResultType,
|
||||
ScoreChannel,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
@@ -78,6 +82,8 @@ const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
|
||||
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
|
||||
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
|
||||
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
|
||||
const CHANNEL_SCOPES = ['athlete', 'team'] as const satisfies readonly ChannelScope[]
|
||||
const CHANNEL_BASES = ['result', 'manual', 'aggregate'] as const satisfies readonly ChannelBasis[]
|
||||
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
|
||||
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
|
||||
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
|
||||
@@ -124,6 +130,19 @@ export function parseTeam(source: unknown): Team {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseScoreChannel(source: unknown): ScoreChannel {
|
||||
const rec = record(source)
|
||||
return {
|
||||
id: num(rec.id),
|
||||
eventId: num(rec.eventId),
|
||||
scope: oneOf(rec.scope, CHANNEL_SCOPES, 'athlete'),
|
||||
basis: oneOf(rec.basis, CHANNEL_BASES, 'result'),
|
||||
profileId: num(rec.profileId),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
countToTeam: normalizeCountToTeam(rec.countToTeam),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEvent(source: unknown): Event {
|
||||
const rec = record(source)
|
||||
return {
|
||||
@@ -143,6 +162,9 @@ export function parseEvent(source: unknown): Event {
|
||||
venue: strOrNull(rec.venue),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
remark: strOrNull(rec.remark),
|
||||
// 旧服务端不下发 avgMode,按默认「只算有效成绩」处理。
|
||||
avgMode: normalizeAvgMode(rec.avgMode),
|
||||
channels: Array.isArray(rec.channels) ? rec.channels.map(parseScoreChannel) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ function buildEvents(): Event[] {
|
||||
venue: null,
|
||||
sortOrder: index,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseEvent, parseScoreChannel } from '../guards'
|
||||
|
||||
// 大屏快照里的 avgMode / countToTeam 是后加的字段:旧服务端不下发时必须有默认值,
|
||||
// 而 basis='aggregate'(新口径「按队内平均」)不能被当成未知取值回落到 result。
|
||||
test('parseEvent 缺 avgMode 时按 valid 处理', () => {
|
||||
assert.equal(parseEvent({ id: 1, code: 'TPULL', eventType: 'individual_team' }).avgMode, 'valid')
|
||||
assert.equal(parseEvent({ id: 1, avgMode: 'all' }).avgMode, 'all')
|
||||
assert.equal(parseEvent({ id: 1, avgMode: 'AVG' }).avgMode, 'valid')
|
||||
})
|
||||
|
||||
test('parseScoreChannel 保留 aggregate 并按 true 补 countToTeam', () => {
|
||||
const channel = parseScoreChannel({ id: 3, eventId: 1, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 1 })
|
||||
assert.equal(channel.basis, 'aggregate')
|
||||
assert.equal(channel.countToTeam, true)
|
||||
|
||||
const personal = parseScoreChannel({ id: 4, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false })
|
||||
assert.equal(personal.countToTeam, false)
|
||||
// 未知 basis 仍回落到 result,避免渲染出不存在的口径。
|
||||
assert.equal(parseScoreChannel({ basis: 'sum' }).basis, 'result')
|
||||
})
|
||||
|
||||
test('parseEvent 同时解析详情接口带出的通道', () => {
|
||||
const event = parseEvent({
|
||||
id: 1,
|
||||
channels: [{ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }],
|
||||
})
|
||||
assert.equal(event.channels?.length, 1)
|
||||
assert.equal(event.channels?.[0]?.countToTeam, false)
|
||||
// 列表接口不下发 channels 时保持 undefined,不能凭空造出空数组。
|
||||
assert.equal(parseEvent({ id: 1 }).channels, undefined)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 计分通道与「个人+团体」口径的归一化。
|
||||
*
|
||||
* 后端旧版本 / 旧缓存里的对象可能没有 avgMode、countToTeam 这两个新字段,
|
||||
* 页面直接读会得到 undefined;统一在这里补默认值,读取处不必各写一份兜底。
|
||||
*/
|
||||
|
||||
import type { AvgMode, Event, EventInput, ScoreChannel } from './types'
|
||||
|
||||
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
|
||||
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
|
||||
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
|
||||
export const DEFAULT_COUNT_TO_TEAM = true
|
||||
|
||||
export function normalizeAvgMode(value: unknown): AvgMode {
|
||||
return typeof value === 'string' && (AVG_MODES as readonly string[]).includes(value)
|
||||
? (value as AvgMode)
|
||||
: DEFAULT_AVG_MODE
|
||||
}
|
||||
|
||||
export function normalizeCountToTeam(value: unknown): boolean {
|
||||
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM
|
||||
}
|
||||
|
||||
/** 只有存在「按队内平均」通道时,录入页才需要显示平均值口径选择。 */
|
||||
export function hasAggregateChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
|
||||
return (channels ?? []).some((channel) => channel.basis === 'aggregate')
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 PUT /api/events/{id} 的载荷。
|
||||
*
|
||||
* 服务端按整体替换处理,所以必须把当前小项的全部可写字段原样回传,
|
||||
* 只改一个 avgMode 而漏掉别的字段会把它们清空。
|
||||
*/
|
||||
export function eventInputOf(event: Event, overrides: Partial<EventInput> = {}): EventInput {
|
||||
const input: EventInput = {
|
||||
sportId: event.sportId,
|
||||
code: event.code,
|
||||
name: event.name,
|
||||
eventType: event.eventType,
|
||||
resultMode: event.resultMode,
|
||||
resultType: event.resultType,
|
||||
compare: event.compare,
|
||||
recordEligible: event.recordEligible,
|
||||
scored: event.scored,
|
||||
multiplier: event.multiplier,
|
||||
status: event.status,
|
||||
scheduledAt: event.scheduledAt,
|
||||
venue: event.venue,
|
||||
sortOrder: event.sortOrder,
|
||||
remark: event.remark,
|
||||
avgMode: normalizeAvgMode(event.avgMode),
|
||||
}
|
||||
return { ...input, ...overrides }
|
||||
}
|
||||
@@ -8,6 +8,15 @@
|
||||
export * from './types'
|
||||
export { ApiClient, ApiError, createApiClient } from './api'
|
||||
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
||||
export {
|
||||
AVG_MODES,
|
||||
DEFAULT_AVG_MODE,
|
||||
DEFAULT_COUNT_TO_TEAM,
|
||||
eventInputOf,
|
||||
hasAggregateChannel,
|
||||
normalizeAvgMode,
|
||||
normalizeCountToTeam,
|
||||
} from './channels'
|
||||
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
||||
export { resolveAssetUrl } from './asset'
|
||||
export { RealtimeClient } from './realtime'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。
|
||||
*/
|
||||
|
||||
import { normalizeCountToTeam } from './channels'
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
@@ -219,6 +220,8 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
|
||||
scope: oneOf(rec.scope, SCOPES, 'athlete'),
|
||||
basis: oneOf(rec.basis, BASES, 'result'),
|
||||
profile,
|
||||
// 旧规则包不写这个字段,缺省按「计入单位总分」处理。
|
||||
countToTeam: normalizeCountToTeam(rec.countToTeam),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Event, ScoreChannel } from '../types'
|
||||
import {
|
||||
eventInputOf,
|
||||
hasAggregateChannel,
|
||||
normalizeAvgMode,
|
||||
normalizeCountToTeam,
|
||||
} from '../channels'
|
||||
|
||||
// 运行:node --test --import ./src/shared/tests/register.mjs src/shared/tests/
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 7,
|
||||
sportId: 1,
|
||||
code: 'TPULL',
|
||||
name: '团体引体向上',
|
||||
eventType: 'individual_team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
multiplier: 2,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: '体育馆',
|
||||
sortOrder: 3,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 7, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
test('avgMode 缺字段 / 非法值时归一为 valid', () => {
|
||||
assert.equal(normalizeAvgMode('all'), 'all')
|
||||
assert.equal(normalizeAvgMode('valid'), 'valid')
|
||||
assert.equal(normalizeAvgMode(undefined), 'valid')
|
||||
assert.equal(normalizeAvgMode(null), 'valid')
|
||||
assert.equal(normalizeAvgMode('avg'), 'valid')
|
||||
assert.equal(normalizeAvgMode(1), 'valid')
|
||||
})
|
||||
|
||||
test('countToTeam 缺字段时默认为 true(旧数据不进单位总分才是例外)', () => {
|
||||
assert.equal(normalizeCountToTeam(true), true)
|
||||
assert.equal(normalizeCountToTeam(false), false)
|
||||
assert.equal(normalizeCountToTeam(undefined), true)
|
||||
assert.equal(normalizeCountToTeam('false'), true)
|
||||
})
|
||||
|
||||
test('只有存在 aggregate 通道时才需要平均值口径', () => {
|
||||
assert.equal(hasAggregateChannel([]), false)
|
||||
assert.equal(hasAggregateChannel(undefined), false)
|
||||
assert.equal(hasAggregateChannel([makeChannel({ basis: 'result' })]), false)
|
||||
assert.equal(hasAggregateChannel([makeChannel({ basis: 'result' }), makeChannel({ basis: 'aggregate' })]), true)
|
||||
})
|
||||
|
||||
test('eventInputOf 回传全部可写字段,并可覆盖 avgMode', () => {
|
||||
const event = makeEvent({ avgMode: 'all' })
|
||||
const input = eventInputOf(event, { avgMode: 'valid' })
|
||||
|
||||
assert.deepEqual(input, {
|
||||
sportId: 1,
|
||||
code: 'TPULL',
|
||||
name: '团体引体向上',
|
||||
eventType: 'individual_team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
multiplier: 2,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: '体育馆',
|
||||
sortOrder: 3,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
})
|
||||
// 旧数据没有 avgMode 时按默认值回传,不能把字段丢掉。
|
||||
assert.equal(eventInputOf({ ...event, avgMode: undefined as unknown as Event['avgMode'] }).avgMode, 'valid')
|
||||
})
|
||||
@@ -72,6 +72,12 @@ export type EventCategoryInput = Omit<EventCategory, 'id' | 'eventId'>
|
||||
export type ChannelScope = 'athlete' | 'team'
|
||||
export type ChannelBasis = 'result' | 'manual' | 'aggregate'
|
||||
|
||||
/**
|
||||
* 「个人+团体」小项里团体成绩的平均值分母口径:
|
||||
* `valid` 只算有有效成绩的人;`all` 全员计入,没成绩的按 0 计入(分母 = 该主体的报名条数)。
|
||||
*/
|
||||
export type AvgMode = 'valid' | 'all'
|
||||
|
||||
export interface ScoreChannel {
|
||||
id: number
|
||||
eventId: number
|
||||
@@ -79,6 +85,8 @@ export interface ScoreChannel {
|
||||
basis: ChannelBasis
|
||||
profileId: number
|
||||
sortOrder: number
|
||||
/** false 表示该通道产生的分只记在个人头上,不进单位总分。 */
|
||||
countToTeam: boolean
|
||||
}
|
||||
|
||||
export type ScoreChannelInput = Omit<ScoreChannel, 'id' | 'eventId'>
|
||||
@@ -101,6 +109,8 @@ export interface Event {
|
||||
venue: string | null
|
||||
sortOrder: number
|
||||
remark: string | null
|
||||
/** 仅「个人+团体」有意义:团体成绩取平均时的分母口径。 */
|
||||
avgMode: AvgMode
|
||||
/** 详情接口才带出,列表接口可能省略。 */
|
||||
categories?: EventCategory[]
|
||||
channels?: ScoreChannel[]
|
||||
@@ -556,6 +566,8 @@ export interface RuleChannel {
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profile: string
|
||||
/** 缺省视为 true;「个人+团体」模板的个人通道会显式写 false。 */
|
||||
countToTeam?: boolean
|
||||
}
|
||||
|
||||
export interface ChannelTemplateMatch {
|
||||
|
||||
Reference in new issue
Block a user