From 6bd7bd1627a918e0506aa2d20d087b81c57ca8bb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 12:02:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=80=8C=E4=B8=AA=E4=BA=BA+=E5=9B=A2?= =?UTF-8?q?=E4=BD=93=E3=80=8D=E9=A1=B9=E7=9B=AE=E7=9C=9F=E6=AD=A3=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E2=80=94=E2=80=94=E9=80=9A=E9=81=93=E7=BA=A7=E4=B8=8D?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=E5=8D=95=E4=BD=8D=20+=20=E9=98=9F=E5=86=85?= =?UTF-8?q?=E5=B9=B3=E5=9D=87=E6=8E=92=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 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 文件通过。 --- docs/使用手册.md | 6 +- server/internal/domain/helpers_test.go | 11 +- server/internal/domain/ledger.go | 4 +- server/internal/domain/ledger_test.go | 15 ++ server/internal/domain/matrix_test.go | 2 +- server/internal/domain/records.go | 17 +- server/internal/domain/scoring.go | 115 ++++++--- server/internal/domain/scoring_test.go | 166 ++++++++++++- server/internal/domain/standings_test.go | 22 ++ server/internal/domain/types.go | 43 +++- server/internal/dto/events.go | 2 + server/internal/export/load.go | 14 +- server/internal/export/load_test.go | 32 +++ server/internal/httpapi/dto.go | 4 + .../internal/httpapi/individual_team_test.go | 216 +++++++++++++++++ server/internal/httpapi/repo.go | 10 + server/internal/httpapi/repo_events.go | 57 +++-- server/internal/httpapi/repo_misc.go | 5 +- server/internal/httpapi/repo_results.go | 18 +- server/internal/httpapi/scoring.go | 23 +- server/internal/realtime/snapshot.go | 14 +- server/internal/rules/apply.go | 5 +- server/internal/rules/apply_test.go | 93 ++++++++ server/internal/rules/convert.go | 7 +- server/internal/rules/diff.go | 7 +- server/internal/rules/diff_test.go | 17 +- server/internal/rules/snapshot.go | 24 +- server/internal/rules/types.go | 8 +- .../event/0017_count_to_team_and_avg_mode.sql | 53 +++++ server/migrations/event/migration_test.go | 220 +++++++++++++++++- web/src/admin/api/mock.ts | 12 +- web/src/admin/api/scoring.ts | 73 ++++-- .../components/baseData/ChannelsSection.vue | 34 ++- .../baseData/SportsEventsSection.vue | 11 +- .../admin/components/baseData/mockBaseData.ts | 22 +- web/src/admin/pages/EntryPage.vue | 107 +++++++++ web/src/admin/pages/avgMode.ts | 54 +++++ web/src/admin/tests/avg-mode.test.ts | 77 ++++++ web/src/admin/tests/scoring.test.ts | 80 ++++++- web/src/display/guards.ts | 22 ++ web/src/display/mock.ts | 1 + web/src/display/tests/guards-channels.test.ts | 33 +++ web/src/shared/channels.ts | 56 +++++ web/src/shared/index.ts | 9 + web/src/shared/rules.ts | 3 + web/src/shared/tests/channels.test.ts | 87 +++++++ web/src/shared/types.ts | 12 + 47 files changed, 1770 insertions(+), 153 deletions(-) create mode 100644 server/internal/httpapi/individual_team_test.go create mode 100644 server/migrations/event/0017_count_to_team_and_avg_mode.sql create mode 100644 web/src/admin/pages/avgMode.ts create mode 100644 web/src/admin/tests/avg-mode.test.ts create mode 100644 web/src/display/tests/guards-channels.test.ts create mode 100644 web/src/shared/channels.ts create mode 100644 web/src/shared/tests/channels.test.ts diff --git a/docs/使用手册.md b/docs/使用手册.md index d201b87..be0e953 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -266,6 +266,8 @@ 4. 删除大项 / 小项会二次确认。 > 「个人+团体」用于团体与个人赛事合并的项目:会同时按个人通道与团体通道记分,具体通道见 10.4「计分通道」。 +> 个人通道的分**只记在个人榜**,不计入单位总分;单位总分只算团体通道——把本单位成员在该组别的成绩取平均后排名再给分。 +> 平均值分母(「平均值口径」)在录入页选择:选「只算有效成绩」时,一个人都没成绩的单位不参与团体排名、不得分;选「全部报名人员都计入」时,没成绩的人按 0 计入,分母是该单位在该组别的报名人数。 ### 10.3 组别 @@ -281,7 +283,9 @@ 1. 先选中一个小项; 2. 点「新增通道」逐条配置,同一「名次范围 + 名次来源」只能有一个通道; 3. 点「保存全部」整体替换该小项的通道配置; -4. 标记为「不计分」的小项,通道可以保存但不参与积分计算。 +4. 标记为「不计分」的小项,通道可以保存但不参与积分计算; +5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**取平均**后排名,并列按积分模板的并列口径处理(默认顺延);平均值分母取录入页选的「平均值口径」; +6. 每条通道有一个「计入单位总分」开关,默认打开。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人。 ### 10.5 积分模板 diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go index 396e36d..74f620b 100644 --- a/server/internal/domain/helpers_test.go +++ b/server/internal/domain/helpers_test.go @@ -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 } diff --git a/server/internal/domain/ledger.go b/server/internal/domain/ledger.go index 84d96df..6b9472c 100644 --- a/server/internal/domain/ledger.go +++ b/server/internal/domain/ledger.go @@ -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 { diff --git a/server/internal/domain/ledger_test.go b/server/internal/domain/ledger_test.go index 3ae285d..476176d 100644 --- a/server/internal/domain/ledger_test.go +++ b/server/internal/domain/ledger_test.go @@ -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)) + } +} diff --git a/server/internal/domain/matrix_test.go b/server/internal/domain/matrix_test.go index a2f7431..1f3dc8f 100644 --- a/server/internal/domain/matrix_test.go +++ b/server/internal/domain/matrix_test.go @@ -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) diff --git a/server/internal/domain/records.go b/server/internal/domain/records.go index 04a58f2..d0cdf22 100644 --- a/server/internal/domain/records.go +++ b/server/internal/domain/records.go @@ -148,14 +148,15 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) { } for _, s := range e.resolvedShares(in.Options.UnionAttribution) { out = append(out, LedgerEntry{ - TeamID: s.TeamID, - AthleteID: athleteID, - EventID: in.Event.ID, - CategoryID: in.Category.ID, - EntryID: e.ID, - SourceType: SourceRecordBonus, - SourceID: e.ID, - Points: b.Points * s.Weight, + TeamID: s.TeamID, + AthleteID: athleteID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + EntryID: e.ID, + SourceType: SourceRecordBonus, + SourceID: e.ID, + Points: b.Points * s.Weight, + CountToTeam: true, }) } } diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index ba0810e..e4e9722 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -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 { @@ -481,16 +533,17 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult continue } out = append(out, LedgerEntry{ - TeamID: s.TeamID, - AthleteID: athleteID, - EventID: in.Event.ID, - CategoryID: in.Category.ID, - EntryID: rr.EntryID, - ResultID: rr.ResultID, - ChannelID: ch.ID, - SourceType: SourceResult, - SourceID: sourceID, - Points: pts, + TeamID: s.TeamID, + AthleteID: athleteID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + EntryID: rr.EntryID, + ResultID: rr.ResultID, + ChannelID: ch.ID, + SourceType: SourceResult, + SourceID: sourceID, + Points: pts, + CountToTeam: ch.countToTeam(), }) } } diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go index d50046d..1fc6368 100644 --- a/server/internal/domain/scoring_test.go +++ b/server/internal/domain/scoring_test.go @@ -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 { diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go index 9e05b82..0138d2f 100644 --- a/server/internal/domain/standings_test.go +++ b/server/internal/domain/standings_test.go @@ -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) + } +} diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index 6c2f3bb..ec86cb8 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -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,11 +324,13 @@ type LedgerEntry struct { SourceType SourceType `json:"sourceType"` SourceID int64 `json:"sourceId"` Points float64 `json:"points"` - Voided bool `json:"voided"` - VoidedBy string `json:"voidedBy,omitempty"` - VoidReason string `json:"voidReason,omitempty"` - CreatedBy string `json:"createdBy,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` + // CountToTeam 为 false 时该流水只记在个人头上,不计入单位总分(库中默认 1)。 + CountToTeam bool `json:"countToTeam"` + Voided bool `json:"voided"` + VoidedBy string `json:"voidedBy,omitempty"` + VoidReason string `json:"voidReason,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` } // StandingsRow 总积分榜的一行。 @@ -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 diff --git a/server/internal/dto/events.go b/server/internal/dto/events.go index 5dea382..40c6772 100644 --- a/server/internal/dto/events.go +++ b/server/internal/dto/events.go @@ -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), } } diff --git a/server/internal/export/load.go b/server/internal/export/load.go index 6da3b58..6eac064 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -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) diff --git a/server/internal/export/load_test.go b/server/internal/export/load_test.go index 4ae470f..71566fe 100644 --- a/server/internal/export/load_test.go +++ b/server/internal/export/load_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 1e50226..d8716bb 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -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"` } diff --git a/server/internal/httpapi/individual_team_test.go b/server/internal/httpapi/individual_team_test.go new file mode 100644 index 0000000..b828e76 --- /dev/null +++ b/server/internal/httpapi/individual_team_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index fdf1bbd..0a8599b 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -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 diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index cb5440c..71e5401 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -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 只能配置一个通道") } diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 9a1da3c..64d4827 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -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) diff --git a/server/internal/httpapi/repo_results.go b/server/internal/httpapi/repo_results.go index 94b4e30..c5aa5f2 100644 --- a/server/internal/httpapi/repo_results.go +++ b/server/internal/httpapi/repo_results.go @@ -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) { @@ -276,15 +276,16 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d out := make([]domain.LedgerEntry, 0) for rows.Next() { var ( - l domain.LedgerEntry - athlete sql.NullInt64 - event sql.NullInt64 - entry sql.NullInt64 - result sql.NullInt64 - created sql.NullString + l domain.LedgerEntry + athlete sql.NullInt64 + event sql.NullInt64 + 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() diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index b019b36..a6ddecf 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -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) } } diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go index a0a141c..a6738ae 100644 --- a/server/internal/realtime/snapshot.go +++ b/server/internal/realtime/snapshot.go @@ -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) @@ -269,13 +269,15 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) { var out []domain.LedgerEntry for rows.Next() { var ( - e domain.LedgerEntry - sourceType string + 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() diff --git a/server/internal/rules/apply.go b/server/internal/rules/apply.go index 743d3f6..a1c5653 100644 --- a/server/internal/rules/apply.go +++ b/server/internal/rules/apply.go @@ -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) } } diff --git a/server/internal/rules/apply_test.go b/server/internal/rules/apply_test.go index c836a3c..6aae484 100644 --- a/server/internal/rules/apply_test.go +++ b/server/internal/rules/apply_test.go @@ -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 +} diff --git a/server/internal/rules/convert.go b/server/internal/rules/convert.go index d04a4d9..d2bba39 100644 --- a/server/internal/rules/convert.go +++ b/server/internal/rules/convert.go @@ -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 按模板编码建索引,便于通道引用解析。 diff --git a/server/internal/rules/diff.go b/server/internal/rules/diff.go index 14a6eb2..7195124 100644 --- a/server/internal/rules/diff.go +++ b/server/internal/rules/diff.go @@ -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 } } diff --git a/server/internal/rules/diff_test.go b/server/internal/rules/diff_test.go index 737d5b5..4bd342f 100644 --- a/server/internal/rules/diff_test.go +++ b/server/internal/rules/diff_test.go @@ -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) + } } diff --git a/server/internal/rules/snapshot.go b/server/internal/rules/snapshot.go index f0f2e4e..56cf8b0 100644 --- a/server/internal/rules/snapshot.go +++ b/server/internal/rules/snapshot.go @@ -243,7 +243,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { return nil, fmt.Errorf("读取项目: %w", err) } - chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order + chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id ORDER BY c.event_id, c.sort_order, c.id`) if err != nil { @@ -253,24 +253,30 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { for chRows.Next() { var ( - eventID int64 - scope string - basis string - code string - sortOrd int + eventID int64 + scope string + basis string + code string + sortOrd int + countToTeam int ) - if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil { + if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil { return nil, fmt.Errorf("读取计分通道: %w", err) } idx, ok := index[eventID] if !ok { continue } - events[idx].Channels = append(events[idx].Channels, Channel{ + ch := Channel{ Scope: domain.Scope(scope), Basis: domain.Basis(basis), Profile: code, - }) + } + // 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。 + if countToTeam == 0 { + ch.CountToTeam = BoolPtr(false) + } + events[idx].Channels = append(events[idx].Channels, ch) } if err := chRows.Err(); err != nil { return nil, fmt.Errorf("读取计分通道: %w", err) diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go index 9bc6c22..114fa0f 100644 --- a/server/internal/rules/types.go +++ b/server/internal/rules/types.go @@ -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"` diff --git a/server/migrations/event/0017_count_to_team_and_avg_mode.sql b/server/migrations/event/0017_count_to_team_and_avg_mode.sql new file mode 100644 index 0000000..11d883b --- /dev/null +++ b/server/migrations/event/0017_count_to_team_and_avg_mode.sql @@ -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')); diff --git a/server/migrations/event/migration_test.go b/server/migrations/event/migration_test.go index 0b8dd19..f686ce2 100644 --- a/server/migrations/event/migration_test.go +++ b/server/migrations/event/migration_test.go @@ -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("迁移后存在外键悬挂") + } +} diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index eada610..e65bb84 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -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, }] }) diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts index d9b6058..29595f1 100644 --- a/web/src/admin/api/scoring.ts +++ b/web/src/admin/api/scoring.ts @@ -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() - for (const candidate of collectCandidates(rows, 'result')) { - const subject = channel.scope === 'team' ? candidate.row.teamId : (candidate.row.athleteId ?? 0) + const buckets = new Map() + 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) - const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points - rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution) + 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) } diff --git a/web/src/admin/components/baseData/ChannelsSection.vue b/web/src/admin/components/baseData/ChannelsSection.vue index d9bc18b..4b43d35 100644 --- a/web/src/admin/components/baseData/ChannelsSection.vue +++ b/web/src/admin/components/baseData/ChannelsSection.vue @@ -1,5 +1,6 @@